Compare commits

...

91 Commits

Author SHA1 Message Date
Eric Bailey df5ff1b72e Override scrollRef types on profile 2023-11-17 10:46:59 -06:00
Eric Bailey 54bf784faf Fix some low-hanging type errors 2023-11-17 10:42:14 -06:00
dan 6c8f043e37 Freeze inactive screens (#1949) 2023-11-16 22:20:08 -08:00
dan a8aed2c38e Use non-reactive callbacks for some methods (#1948)
* Use non-reactive callbacks for some methods

* Remove unnecessary state

* Use non-reactive callbacks for modals
2023-11-16 22:18:37 -08:00
dan a10adf8908 Add some memoization (#1947) 2023-11-16 18:52:41 -08:00
Paul Frazee e358c3cc30 Improve notification load behaviors (#1943)
* Dont use the stale cache for notifs-feed

* Add a delay to marking all read to avoid marking upcoming posts as read

* Trigger automatic notifications refresh when navigating to the tab, in certain conditions
2023-11-16 18:27:05 -08:00
Paul Frazee 357c752a21 Move the current agent to a global and reset RQ queries on agent change (#1946) 2023-11-16 18:26:22 -08:00
dan 3043b32468 Fix jumpy modal regression (#1945) 2023-11-16 18:01:51 -08:00
Paul Frazee c03c744008 Notifs refresh improvement (#1941)
* Show loading state in notifications

* Fix invalidation of notifs

* Move spinner below header

* Fix: dont show spinner on initial load
2023-11-16 15:50:57 -08:00
Eric Bailey 6f57192bd5 Revise cache times (#1942)
(cherry picked from commit 1be2060a2b)
2023-11-16 15:43:53 -08:00
Paul Frazee 1952705ded Disable refetchOnWindowFocus on react-query (#1936) 2023-11-16 15:42:37 -08:00
Eric Bailey 6dfb2a232f Caching heuristics (#1938)
* Tempfix profile load

* First pass at staleTime
2023-11-16 15:28:50 -08:00
dan f89dc63801 Add profile loading shim (#1940) 2023-11-16 23:23:58 +00:00
dan 22df70b3cc Fix blank feed (#1935)
* Split feed into two components

* Rm unused

* Add a spinner
2023-11-16 14:37:48 -08:00
dan 8475312422 Add a mutation queue to fix race conditions in toggles (#1933)
* Prototype a queue

* Track both current and pending actions

* Skip unnecessary actions

* Commit last confirmed state to shadow

* Thread state through actions over time

* Fix the logic to skip redundant mutations

* Track status

* Extract an abstraction

* Fix standalone mutations

* Add types

* Move to another file

* Return stable function

* Clean up

* Use queue for muting

* Use queue for blocking

* Convert other follow buttons

* Don't export non-queue mutations

* Properly handle canceled tasks

* Fix copy paste
2023-11-16 14:01:01 -08:00
Paul Frazee 54faa7e176 Remove deprecated models and mobx usage (react-query refactor) (#1934)
* Update login page to use service query

* Update modal to use session instead of store

* Move image sizes cache off store

* Update settings to no longer use store

* Update link-meta fetch to use agent instead of rootstore

* Remove deprecated resolveName()

* Delete deprecated link-metas cache

* Delete deprecated posts cache

* Delete all remaining mobx models, including the root store

* Strip out unused mobx observer wrappers
2023-11-16 12:53:43 -08:00
Paul Frazee e637798e05 Refactor account-creation to use react-query and a reducer (react-query refactor) (#1931)
* Refactor account-creation to use react-query and a reducer

* Add translations

* Missing translate
2023-11-16 11:16:31 -08:00
Paul Frazee 9f7a162a96 Refactor app passwords to use react-query (#1932) 2023-11-16 11:11:56 -08:00
Eric Bailey 310a7eaca7 Some me model cleanup (#1928)
* Replace me model in post dropdown btn

* Replace delete account logic

* Replace me model in bottom bar web

* Replace me model in bottom bar

* Replace me model in reply prompt

* Better fallback

* Fix reference

* Fix bad ref in bottom bar
2023-11-16 10:54:03 -08:00
Eric Bailey a652b52b88 Refactor ChangeHandle modal (#1929)
* Refactor ChangeHandle to use new methods

* Better telemetry

* Remove unused logic

* Remove caching

* Add error message

* Persist service changes, don't fall back on change handle
2023-11-16 09:16:16 -08:00
Eric Bailey e6efeea7c0 Refactor invites modal (#1930)
* Refactor invites modal

* Replace in drawer

* Delete stuff from me model
2023-11-16 08:40:31 -08:00
Paul Frazee 8a1fd160e6 Update UserInfoText component (#1927) 2023-11-16 08:19:08 -08:00
Paul Frazee a84b2f9f2f Close active elems (react-query refactor) (#1926)
* Refactor closeAny and closeAllActiveElements

* Add close lightbox

* Switch to hooks

* Fixes
2023-11-16 08:18:59 -08:00
Eric Bailey 0de8d40981 Misc cleanup (#1925)
* Remove unused prefs

* Cleanup

* Remove my-follows cache

* Replace moderationOpts in ProfileCard comp

* Replace moderationOpts in FeedSlice

* Remove preferences model
2023-11-15 18:35:58 -08:00
Paul Frazee e749f2f3a5 Factor lightbox out into hook/context (#1919) 2023-11-15 18:17:03 -08:00
Paul Frazee 03b20c70e4 Fix memo params causing tuner to lose state (#1924) 2023-11-15 17:42:15 -08:00
Eric Bailey 952f5033d0 Fixes analytics init on web (#1923) 2023-11-15 17:37:55 -08:00
Eric Bailey 610eeecd26 Fix onboarding follows (#1922) 2023-11-15 17:37:32 -08:00
Eric Bailey 8857ba70c6 Improve types (#1921) 2023-11-15 17:23:59 -08:00
Eric Bailey d8b26edb56 Remove post thread (#1920)
* Delete post-thread model

* Remove post-thread-item

* Remove unused types
2023-11-15 17:18:45 -08:00
Paul Frazee 6616b2bff0 Shell behaviors update (react-query refactor) (#1915)
* Move tick-every-minute into a hook/context

* Move soft-reset event out of the shell model

* Update soft-reset listener on new search page

* Implement session-loaded and session-dropped events

* Update analytics and push-notifications to use new session system
2023-11-15 17:17:50 -08:00
Eric Bailey f23e9978d8 Update post embed fetching to use new methods (#1916)
* Update post embed fetching to use new methods

* Use session agent
2023-11-15 17:09:13 -08:00
Eric Bailey 9bcd00b831 No need to await follows anymore, fix cold load search (#1918) 2023-11-15 17:08:08 -08:00
Eric Bailey fe1a7183fc Translate some things on search (#1917) 2023-11-15 19:00:43 -06:00
Eric Bailey 22b76423a0 Search page (#1912)
* Desktop web work

* Mobile search

* Dedupe suggestions

* Clean up and reorg

* Cleanup

* Cleanup

* Use Pager

* Delete unused code

* Fix conflicts

* Remove search ui model

* Soft reset

* Fix scrollable results, remove observer

* Use correct ScrollView

* Clean up layout

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2023-11-15 15:55:28 -08:00
Paul Frazee d5ea31920c Autocomplete updates (react-query refactor) (#1911)
* Unify the autocomplete code; drop fuse

* Persist autocomplete results while they're in progress

* Commit lockfile

* Use ReturnType helper

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2023-11-15 14:39:22 -08:00
dan 839e8e8d0a Post PostLikedBy and PostRepostedBy to RQ (#1913)
* Port PostRepostedBy to RQ

* Port PostLikedBy to RQ
2023-11-15 14:04:25 -08:00
dan e699df21c6 Port Profile Followers/Follows to RQ (#1893)
* Port user followers to RQ

* Port user follows to RQ

* Start porting FollowButton to RQ

* Fix RQ key

* Check pending

* Fix shadow and pending states

* Rm unused

* Remove last usage of useFollowProfile
2023-11-14 17:55:54 -08:00
Eric Bailey d1cb74febe Desktop user autocomplete search (#1906)
* Fix notification provider order, add comments

* Remove log

* Add actor typeahead handling

* Trim down desktop search styles and hooks

* Clean up moderation
2023-11-14 17:51:23 -08:00
Eric Bailey ab1ce078ec Fix openCamera, openCropped, lightbox model, and image model usages (#1908) 2023-11-14 17:46:46 -08:00
Eric Bailey 7d5e01f433 Fix profile list (#1907) 2023-11-14 17:45:38 -08:00
Paul Frazee e08d06f263 Fix image order in 4-image post embeds (close #1895) (#1904) 2023-11-14 17:10:58 -06:00
Eric Bailey 68767d597e Fix notification provider order, add comments (#1905)
* Fix notification provider order, add comments

* Remove log
2023-11-14 16:54:02 -06:00
Eric Bailey ab6e3f2c5d Fix self mention, resolve handle (#1903)
* Fix self mention, resolve handle

* Use queryClient

* Fix type

* Remove staleTime
2023-11-14 14:16:56 -06:00
Paul Frazee 8e4a3ad5b6 Add Shadow type (#1900) 2023-11-14 12:10:39 -08:00
Eric Bailey 00f8c8b06d Fix root sibling context issue (#1902) 2023-11-14 10:46:37 -08:00
Paul Frazee 0a26e78dcb Composer update (react-query refactor) (#1899)
* Move composer state to a context

* Rework composer to use RQ

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2023-11-14 10:41:55 -08:00
Paul Frazee c687172de9 Fix poll latest loop (#1901)
* Avoid pollLatest loop

* Set poll interval on profile feeds
2023-11-14 12:33:05 -06:00
Eric Bailey 3fde1bea1b Handle end of feed (#1898)
* Handle end of feed

* Add isFetching
2023-11-14 11:28:40 -06:00
Eric Bailey 4355f0fd9a Refactor onboarding suggested follows (#1897)
* Refactor onboarding suggested follows

* Fix error state, track call

* Remove todo

* Use flatmap

* Add additional try catch

* Remove todo
2023-11-14 11:25:37 -06:00
Paul Frazee a81c4b68fa Update Muted and Blocked accounts screens (react-query refactor) (#1892)
* Add my-blocked-accounts and my-muted-accounts queries

* Update ProfileCard to use the profile shadow cache and useModerationOpts

* Update blocked accounts and muted accounts screens
2023-11-13 17:30:56 -08:00
Paul Frazee 0501c2be77 Profile cleanup (react-query refactor) (#1891)
* Only fetch profile tab content when focused

* Fix keys

* Add missing behaviors to post tabs

* Delete old profile mobx model
2023-11-13 15:12:41 -08:00
Paul Frazee 47204d9551 Update profile preview to use react-query (#1890) 2023-11-13 14:57:53 -08:00
Paul Frazee 9fca7b3af6 Add feedgens tab to profile (#1889) 2023-11-13 14:46:19 -08:00
Eric Bailey b04748e703 Factor out store from post-feed query (#1888) 2023-11-13 14:07:18 -08:00
Eric Bailey 06eb8b9a4c Factor our feed source model (#1887)
* Refactor first onboarding step

* Replace old FeedSourceCard

* Clean up CustomFeedEmbed

* Remove discover feeds model

* Refactor ProfileFeed screen

* Remove useCustomFeed

* Delete some unused models

* Rip out more prefs

* Factor out treeView from thread comp

* Improve last commit
2023-11-13 13:53:57 -08:00
Paul Frazee a01463788d More profile refactor updates (#1886)
* Update the profile avatar lightbox

* Update profile editor

* Add dynamic likes tab

* Add dynamic feeds and lists tabs

* Implement lists listing on profiles
2023-11-13 13:29:33 -08:00
dan 8217761363 Fix jump for web pager (#1884) 2023-11-13 13:03:47 -08:00
dan bb4ed3cd49 Fix lint on main (#1885) 2023-11-13 13:01:26 -08:00
dan e1938931e0 Refactor profile screen to use new pager and react-query (#1870)
* Profile tabs WIP

* Refactor the profile screen to use react-query (WIP)

* Add the profile shadow and get follow, mute, and block working

* Cleanup

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2023-11-13 10:35:15 -08:00
Eric Bailey c3edde8ac6 More prefs cleanup (#1882)
* Update useHomeTabs

* Use queryClient to fetch

* Replace useHomeTabs and useDesktopRightNavItems

* Fix type error

* Some cleanup
2023-11-13 10:21:53 -08:00
Eric Bailey 37a2204483 Update clear account behavior, ensure agent is set (#1881) 2023-11-13 08:20:38 -08:00
Paul Frazee b445c15cc9 Refactor notifications to use react-query (#1878)
* Move broadcast channel to lib

* Refactor view/com/post/Post and remove temporary 2 components

* Add useModerationOpts hook

* Refactor notifications to use react-query

* Fix: only trigger updates in useModerationOpts when the values have changed

* Implement unread notification tracking

* Add moderation filtering to notifications

* Handle native/push notifications

* Remove dead code

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2023-11-12 18:13:11 -08:00
Eric Bailey c584a3378d Refactor My Feeds (#1877)
* Refactor My Feeds screen

* Remove unused feed UI models

* Add back PTR
2023-11-12 16:26:02 -08:00
Paul Frazee d9e0a927c1 Refactor lists to use new queries (#1875)
* Refactor lists queries to react-query

* Delete old lists-list model

* Implement list, list-members, and list-memberships react-queries

* Update CreateOrEditList modal

* First pass at my-follows and actor-autocomplete queries

* Update ListAddUserModal to use new queries, change to ListAddRemoveUsersModal

* Update UserAddRemoveLists modal

* Remove old TODO

* Fix indent, autocomplete query

* Add a todo

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2023-11-12 12:45:25 -08:00
Eric Bailey 05b728fffc Eric/preferences (#1873)
* Add initial preferences query, couple mutations

* Remove unused

* Clean up labels, migrate getModerationOpts

* Add birth date handling

* Migrate feed prefs

* Migrate thread view prefs

* Migrate homeFeed to use existing key name

* Fix up saved feeds in response, no impl yet

* Migrate saved feeds to new hooks

* Clean up more of preferences

* Fix PreferencesThreads load state

* Fix modal dismissal

* Small spacing fix

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2023-11-12 11:31:11 -08:00
Paul Frazee c8c308e31e Refactor feeds to use react-query (#1862)
* Update to react-query v5

* Introduce post-feed react query

* Add feed refresh behaviors

* Only fetch feeds of visible pages

* Implement polling for latest on feeds

* Add moderation filtering to slices

* Handle block errors

* Update feed error messages

* Remove old models

* Replace simple-feed option with disable-tuner option

* Add missing useMemo

* Implement the mergefeed and fixes to polling

* Correctly handle failed load more state

* Improve error and empty state behaviors

* Clearer naming
2023-11-10 15:34:25 -08:00
dan 51f04b9620 Use min height for pager lists and increase it (#1869) 2023-11-10 22:29:12 +00:00
dan 86b4842d67 Fix initial pager gap after fast scroll (#1868) 2023-11-10 21:23:17 +00:00
dan 91f8a23fbc Scroll sync in the pager without jumps (#1863) 2023-11-10 19:54:33 +00:00
dan 65def37165 Push useAnimatedScrollHandler down everywhere to work around bugs (#1866)
* Move useOnMainScroll handlers to leaves

* Force Feed to always take handlers

* Pass handlers from the pager
2023-11-10 19:00:46 +00:00
Eric Bailey e0e5bc8fd8 Fix type errors (#1867) 2023-11-10 18:20:33 +00:00
Eric Bailey 6513055d02 Merge pull request #1860 from bluesky-social/eric/startup
Web login/signup and shell
2023-11-10 11:31:36 -06:00
Eric Bailey 436a14eabb Pare down session as much as possible 2023-11-10 09:59:04 -06:00
Eric Bailey d0d93168d4 Clean up isDirty handling 2023-11-10 08:59:39 -06:00
dan 8d7475c130 Work around web stale closure bug in Reanimated (#1865) 2023-11-10 14:58:13 +00:00
Eric Bailey 499021229a Remove error states, just best effort 2023-11-10 08:53:23 -06:00
Eric Bailey 60386f8f07 Swap order 2023-11-10 08:48:15 -06:00
Eric Bailey b0c9cce5c3 Follow conventions for query, use isDirty flag in session store to avoid unneccessary writes 2023-11-10 08:46:45 -06:00
Eric Bailey 742f53d1ec Comments 2023-11-09 20:46:45 -06:00
Eric Bailey 2d7b89c6a1 Cleanup getProfile 2023-11-09 20:39:54 -06:00
Eric Bailey ab878ba9a6 Web login/signup and shell 2023-11-09 20:35:17 -06:00
dan 487d871cfd Pull useAnimatedScrollHandler back up (#1858)
* Revert "Pull animated scroll handler down from pager"

This reverts commit ecebb78e40.

* Only handle onScroll for current page
2023-11-09 23:47:54 +00:00
Paul Frazee fb4f5709c4 Refactor post threads to use react query (#1851)
* Add post and post-thread queries

* Update PostThread components to use new queries

* Move from normalized cache to shadow cache model

* Merge post shadow into the post automatically

* Remove dead code

* Remove old temporary session

* Fix: set agent on session creation

* Temporarily double-login

* Handle post-thread uri resolution errors
2023-11-09 15:35:25 -08:00
Eric Bailey 625cbc435f First pass at a session handler (#1850)
* First pass at a session handler

* TODOs

* Fix recursion

* Couple more things

* Add back resume session concept

* Handle ready

* Cleanup of initial loading states

* Handle init failure

* Cleanup

* Remove account

* Add updateCurrentAccount

* Remove log

* Cleanup

* Integrate removeAccount

* Add hasSession

* Add to App.native, harden migration

* Use effect to persist data
2023-11-09 15:14:51 -08:00
dan 664e7a91a9 Fix tab alignment on the web (#1857) 2023-11-09 21:56:25 +00:00
dan c627a766cd Show tabs when swiping feeds (#1856) 2023-11-09 21:45:31 +00:00
dan 7a55ca6133 Sync top/bottom bar disappearance to the scroll (#1855)
* Disable existing code that toggles shell

* Make shell mode a float

* Translate based on the gesture

* Track header and footer heights

* Add web support

* Fix types and cleanup

* Add back isScrolled logic

* Add comments
2023-11-09 12:15:05 -08:00
Ansh 1dcf882619 Hotfix internationalization on mobile (#1854)
* hotfix internationalization on mobile

* remove comment
2023-11-09 10:49:46 -08:00
Ansh 4c7850f8c4 Internationalization & localization (#1822)
* install and setup lingui

* setup dynamic locale activation and async loading

* first pass of automated replacement of text messages

* add some more documentaton

* fix nits

* add `es` and `hi`locales for testing purposes

* make accessibilityLabel localized

* compile and extract new messages

* fix merge conflicts

* fix eslint warning

* change instructions from sending email to opening PR

* fix comments
2023-11-09 10:04:16 -08:00
dan 82059b7ee1 Hide/show header and footer without re-renders, take two (#1849)
* Remove callsites using the state value

* Remove unused code

* Change shell mode without re-renders

* Adjust "write your reply" for mode
2023-11-09 00:25:27 +00:00
337 changed files with 24467 additions and 16703 deletions
+1
View File
@@ -45,6 +45,7 @@ module.exports = function (api) {
},
},
],
'macros',
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
],
env: {
+113
View File
@@ -0,0 +1,113 @@
# 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
@@ -0,0 +1,11 @@
/** @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',
}
+9 -3
View File
@@ -28,7 +28,9 @@
"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"
"build:apk": "eas build -p android --profile dev-android-apk",
"intl:extract": "lingui extract",
"intl:compile": "lingui compile"
},
"dependencies": {
"@atproto/api": "^0.6.23",
@@ -42,6 +44,7 @@
"@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",
@@ -60,7 +63,7 @@
"@segment/analytics-react-native": "^2.10.1",
"@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.10.0",
"@tanstack/react-query": "^4.33.0",
"@tanstack/react-query": "^5.8.1",
"@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220",
"@tiptap/extension-hard-break": "^2.0.3",
@@ -164,10 +167,12 @@
},
"devDependencies": {
"@atproto/dev-env": "^0.2.5",
"@babel/core": "^7.20.0",
"@babel/core": "^7.23.2",
"@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",
@@ -192,6 +197,7 @@
"@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",
+67 -35
View File
@@ -5,16 +5,17 @@ import React, {useState, useEffect} from 'react'
import {RootSiblingParent} from 'react-native-root-siblings'
import * as SplashScreen from 'expo-splash-screen'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {observer} from 'mobx-react-lite'
import {QueryClientProvider} from '@tanstack/react-query'
import {enableFreeze} from 'react-native-screens'
import 'view/icons'
import {init as initPersistedState} from '#/state/persisted'
import {init as initReminders} from '#/state/shell/reminders'
import {listenSessionDropped} from './state/events'
import {useColorMode} from 'state/shell'
import {ThemeProvider} from 'lib/ThemeContext'
import {s} from 'lib/styles'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from 'view/shell'
import * as notifications from 'lib/notifications/notifications'
import * as analytics from 'lib/analytics/analytics'
@@ -23,51 +24,72 @@ 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()
const InnerApp = observer(function AppImpl() {
function InnerApp() {
const colorMode = useColorMode()
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
// init
useEffect(() => {
setupState().then(store => {
setRootStore(store)
analytics.init(store)
notifications.init(store)
store.onSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
initReminders()
analytics.init()
notifications.init(queryClient)
listenSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
}, [])
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
// show nothing prior to init
if (!rootStore) {
if (isInitialLoad) {
// TODO add a loading state
return null
}
/*
* Session and initial state should be loaded prior to rendering below.
*/
return (
<QueryClientProvider client={queryClient}>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</RootSiblingParent>
</I18nProvider>
</analytics.Provider>
</ThemeProvider>
</QueryClientProvider>
</UnreadNotifsProvider>
)
})
}
function App() {
const [isReady, setReady] = useState(false)
@@ -80,18 +102,28 @@ function App() {
return null
}
/*
* NOTE: only nothing here can depend on other data or session state, since
* that is set up in the InnerApp component above.
*/
return (
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<InnerApp />
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
<QueryClientProvider client={queryClient}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
)
}
+63 -33
View File
@@ -1,63 +1,83 @@
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'
const InnerApp = observer(function AppImpl() {
enableFreeze(true)
function InnerApp() {
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
// init
useEffect(() => {
setupState().then(store => {
setRootStore(store)
analytics.init(store)
})
}, [])
initReminders()
analytics.init()
dynamicActivate(defaultLocale) // async import of locale data
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
// show nothing prior to init
if (!rootStore) {
if (isInitialLoad) {
// TODO add a loading state
return null
}
/*
* Session and initial state should be loaded prior to rendering below.
*/
return (
<QueryClientProvider client={queryClient}>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<ToastContainer />
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</RootSiblingParent>
</I18nProvider>
<ToastContainer />
</analytics.Provider>
</ThemeProvider>
</QueryClientProvider>
</UnreadNotifsProvider>
)
})
}
function App() {
const [isReady, setReady] = useState(false)
@@ -70,18 +90,28 @@ function App() {
return null
}
/*
* NOTE: only nothing here can depend on other data or session state, since
* that is set up in the InnerApp component above.
*/
return (
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<InnerApp />
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
<QueryClientProvider client={queryClient}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
)
}
+8 -10
View File
@@ -1,7 +1,6 @@
import * as React from 'react'
import {StyleSheet} from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
import {observer} from 'mobx-react-lite'
import {
NavigationContainer,
createNavigationContainerRef,
@@ -33,10 +32,10 @@ import {isNative} from 'platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from './state'
import {bskyTitle} from 'lib/strings/headings'
import {JSX} from 'react/jsx-runtime'
import {timeout} from 'lib/async/timeout'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {HomeScreen} from './view/screens/Home'
import {SearchScreen} from './view/screens/Search'
@@ -346,7 +345,7 @@ function NotificationsTabNavigator() {
)
}
const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
function MyProfileTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
return (
<MyProfileTab.Navigator
@@ -368,18 +367,17 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
{commonScreens(MyProfileTab as typeof HomeTab)}
</MyProfileTab.Navigator>
)
})
}
/**
* The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack.
*/
const FlatNavigator = observer(function FlatNavigatorImpl() {
const FlatNavigator = () => {
const pal = usePalette('default')
const store = useStores()
const unreadCountLabel = store.me.notifications.unreadCountLabel
const numUnread = useUnreadNotifications()
const title = (page: string) => bskyTitle(page, unreadCountLabel)
const title = (page: string) => bskyTitle(page, numUnread)
return (
<Flat.Navigator
screenOptions={{
@@ -409,10 +407,10 @@ const FlatNavigator = observer(function FlatNavigatorImpl() {
getComponent={() => NotificationsScreen}
options={{title: title('Notifications')}}
/>
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
)
})
}
/**
* The RoutesContainer should wrap all components which need access
+53 -39
View File
@@ -1,16 +1,26 @@
import React from 'react'
import {AppState, AppStateStatus} from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
createClient,
AnalyticsProvider,
useAnalytics as useAnalyticsOrig,
ClientMethods,
} from '@segment/analytics-react-native'
import {RootStoreModel, AppInfo} from 'state/models/root-store'
import {useStores} from 'state/models/root-store'
import {z} from 'zod'
import {useSession} from '#/state/session'
import {sha256} from 'js-sha256'
import {ScreenEvent, TrackEvent} from './types'
import {logger} from '#/logger'
import {listenSessionLoaded} from '#/state/events'
export const appInfo = z.object({
build: z.string().optional(),
name: z.string().optional(),
namespace: z.string().optional(),
version: z.string().optional(),
})
export type AppInfo = z.infer<typeof appInfo>
const segmentClient = createClient({
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
@@ -21,10 +31,10 @@ const segmentClient = createClient({
export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent
export function useAnalytics() {
const store = useStores()
const {hasSession} = useSession()
const methods: ClientMethods = useAnalyticsOrig()
return React.useMemo(() => {
if (store.session.hasSession) {
if (hasSession) {
return {
screen: methods.screen as ScreenEvent, // ScreenEvents defines all the possible screen names
track: methods.track as TrackEvent, // TrackEvents defines all the possible track events and their properties
@@ -45,21 +55,18 @@ export function useAnalytics() {
alias: () => Promise<void>,
reset: () => Promise<void>,
}
}, [store, methods])
}, [hasSession, methods])
}
export function init(store: RootStoreModel) {
store.onSessionLoaded(() => {
const sess = store.session.currentSession
if (sess) {
if (sess.did) {
const did_hashed = sha256(sess.did)
segmentClient.identify(did_hashed, {did_hashed})
logger.debug('Ping w/hash')
} else {
logger.debug('Ping w/o hash')
segmentClient.identify()
}
export function init() {
listenSessionLoaded(account => {
if (account.did) {
const did_hashed = sha256(account.did)
segmentClient.identify(did_hashed, {did_hashed})
logger.debug('Ping w/hash')
} else {
logger.debug('Ping w/o hash')
segmentClient.identify()
}
})
@@ -67,7 +74,7 @@ export function init(store: RootStoreModel) {
// this is a copy of segment's own lifecycle event tracking
// we handle it manually to ensure that it never fires while the app is backgrounded
// -prf
segmentClient.isReady.onChange(() => {
segmentClient.isReady.onChange(async () => {
if (AppState.currentState !== 'active') {
logger.debug('Prevented a metrics ping while the app was backgrounded')
return
@@ -78,35 +85,29 @@ export function init(store: RootStoreModel) {
return
}
const oldAppInfo = store.appInfo
const oldAppInfo = await readAppInfo()
const newAppInfo = context.app as AppInfo
store.setAppInfo(newAppInfo)
writeAppInfo(newAppInfo)
logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
if (typeof oldAppInfo === 'undefined') {
if (store.session.hasSession) {
segmentClient.track('Application Installed', {
version: newAppInfo.version,
build: newAppInfo.build,
})
}
} else if (newAppInfo.version !== oldAppInfo.version) {
if (store.session.hasSession) {
segmentClient.track('Application Updated', {
version: newAppInfo.version,
build: newAppInfo.build,
previous_version: oldAppInfo.version,
previous_build: oldAppInfo.build,
})
}
}
if (store.session.hasSession) {
segmentClient.track('Application Opened', {
from_background: false,
segmentClient.track('Application Installed', {
version: newAppInfo.version,
build: newAppInfo.build,
})
} else if (newAppInfo.version !== oldAppInfo.version) {
segmentClient.track('Application Updated', {
version: newAppInfo.version,
build: newAppInfo.build,
previous_version: oldAppInfo.version,
previous_build: oldAppInfo.build,
})
}
segmentClient.track('Application Opened', {
from_background: false,
version: newAppInfo.version,
build: newAppInfo.build,
})
})
let lastState: AppStateStatus = AppState.currentState
@@ -130,3 +131,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider>
)
}
async function writeAppInfo(value: AppInfo) {
await AsyncStorage.setItem('BSKY_APP_INFO', JSON.stringify(value))
}
async function readAppInfo(): Promise<AppInfo | undefined> {
const rawData = await AsyncStorage.getItem('BSKY_APP_INFO')
const obj = rawData ? JSON.parse(rawData) : undefined
if (!obj || typeof obj !== 'object') {
return undefined
}
return obj
}
+11 -11
View File
@@ -4,10 +4,11 @@ import {
AnalyticsProvider,
useAnalytics as useAnalyticsOrig,
} from '@segment/analytics-react'
import {RootStoreModel} from 'state/models/root-store'
import {useStores} from 'state/models/root-store'
import {sha256} from 'js-sha256'
import {useSession} from '#/state/session'
import {logger} from '#/logger'
import {listenSessionLoaded} from '#/state/events'
const segmentClient = createClient(
{
@@ -24,10 +25,10 @@ const segmentClient = createClient(
export const track = segmentClient?.track?.bind?.(segmentClient)
export function useAnalytics() {
const store = useStores()
const {hasSession} = useSession()
const methods = useAnalyticsOrig()
return React.useMemo(() => {
if (store.session.hasSession) {
if (hasSession) {
return methods
}
// dont send analytics pings for anonymous users
@@ -40,15 +41,14 @@ export function useAnalytics() {
alias: () => {},
reset: () => {},
}
}, [store, methods])
}, [hasSession, methods])
}
export function init(store: RootStoreModel) {
store.onSessionLoaded(() => {
const sess = store.session.currentSession
if (sess) {
if (sess.did) {
const did_hashed = sha256(sess.did)
export function init() {
listenSessionLoaded(account => {
if (account.did) {
if (account.did) {
const did_hashed = sha256(account.did)
segmentClient.identify(did_hashed, {did_hashed})
logger.debug('Ping w/hash')
} else {
+14 -3
View File
@@ -4,7 +4,7 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
} from '@atproto/api'
import {FeedSourceInfo} from './feed/types'
import {ReasonFeedSource} from './feed/types'
import {isPostInLanguage} from '../../locale/helpers'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
@@ -65,9 +65,9 @@ export class FeedViewPostsSlice {
)
}
get source(): FeedSourceInfo | undefined {
get source(): ReasonFeedSource | undefined {
return this.items.find(item => '__source' in item && !!item.__source)
?.__source as FeedSourceInfo
?.__source as ReasonFeedSource
}
containsUri(uri: string) {
@@ -116,6 +116,17 @@ 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()
+12 -13
View File
@@ -1,38 +1,37 @@
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 rootStore: RootStoreModel,
public agent: BskyAgent,
public params: GetAuthorFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getAuthorFeed({
const res = await this.agent.getAuthorFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getAuthorFeed({
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getAuthorFeed({
...this.params,
cursor: this.cursor,
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: this._filter(res.data.feed),
+12 -13
View File
@@ -1,38 +1,37 @@
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 rootStore: RootStoreModel,
public agent: BskyAgent,
public params: GetCustomFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
const res = await this.agent.app.bsky.feed.getFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getFeed({
...this.params,
cursor: this.cursor,
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
+12 -14
View File
@@ -1,30 +1,28 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
export class FollowingFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(public rootStore: RootStoreModel) {}
reset() {
this.cursor = undefined
}
constructor(public agent: BskyAgent) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getTimeline({
const res = await this.agent.getTimeline({
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getTimeline({
cursor: this.cursor,
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getTimeline({
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
+12 -13
View File
@@ -1,38 +1,37 @@
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 rootStore: RootStoreModel,
public agent: BskyAgent,
public params: GetActorLikes.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getActorLikes({
const res = await this.agent.getActorLikes({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getActorLikes({
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getActorLikes({
...this.params,
cursor: this.cursor,
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
+12 -13
View File
@@ -1,38 +1,37 @@
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 rootStore: RootStoreModel,
public agent: BskyAgent,
public params: GetListFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
const res = await this.agent.app.bsky.feed.getListFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getListFeed({
...this.params,
cursor: this.cursor,
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
+49 -39
View File
@@ -1,11 +1,12 @@
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
import shuffle from 'lodash.shuffle'
import {RootStoreModel} from 'state/index'
import {timeout} from 'lib/async/timeout'
import {bundleAsync} from 'lib/async/bundle'
import {feedUriToHref} from 'lib/strings/url-helpers'
import {FeedTuner} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -17,28 +18,49 @@ export class MergeFeedAPI implements FeedAPI {
itemCursor = 0
sampleCursor = 0
constructor(public rootStore: RootStoreModel) {
this.following = new MergeFeedSource_Following(this.rootStore)
constructor(
public agent: BskyAgent,
public params: FeedParams,
public feedTuners: FeedTunerFn[],
) {
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
}
reset() {
this.following = new MergeFeedSource_Following(this.rootStore)
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
this.feedCursor = 0
this.itemCursor = 0
this.sampleCursor = 0
if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
this.customFeeds = shuffle(
this.params.mergeFeedSources.map(
feedUri =>
new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners),
),
)
} else {
this.customFeeds = []
}
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getTimeline({
const res = await this.agent.getTimeline({
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
// we capture here to ensure the data has loaded
this._captureFeedsIfNeeded()
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
if (!cursor) {
this.reset()
}
const promises = []
@@ -76,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI {
}
return {
cursor: posts.length ? 'fake' : undefined,
cursor: posts.length ? String(this.itemCursor) : undefined,
feed: posts,
}
}
@@ -107,28 +129,15 @@ export class MergeFeedAPI implements FeedAPI {
// provide follow
return this.following.take(1)
}
_captureFeedsIfNeeded() {
if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) {
return
}
if (this.customFeeds.length === 0) {
this.customFeeds = shuffle(
this.rootStore.preferences.savedFeeds.map(
feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri),
),
)
}
}
}
class MergeFeedSource {
sourceInfo: FeedSourceInfo | undefined
sourceInfo: ReasonFeedSource | undefined
cursor: string | undefined = undefined
queue: AppBskyFeedDefs.FeedViewPost[] = []
hasMore = true
constructor(public rootStore: RootStoreModel) {}
constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
get numReady() {
return this.queue.length
@@ -190,16 +199,12 @@ class MergeFeedSource_Following extends MergeFeedSource {
cursor: string | undefined,
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.rootStore.agent.getTimeline({cursor, limit})
const res = await this.agent.getTimeline({cursor, limit})
// run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune(
res.data.feed,
this.rootStore.preferences.getFeedTuners('home'),
{
dryRun: false,
maintainOrder: true,
},
)
const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
dryRun: false,
maintainOrder: true,
})
res.data.feed = slices.map(slice => slice.rootItem)
return res
}
@@ -208,14 +213,19 @@ class MergeFeedSource_Following extends MergeFeedSource {
class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date
constructor(public rootStore: RootStoreModel, public feedUri: string) {
super(rootStore)
constructor(
public agent: BskyAgent,
public feedUri: string,
public feedTuners: FeedTunerFn[],
) {
super(agent, feedTuners)
this.sourceInfo = {
$type: 'reasonFeedSource',
displayName: feedUri.split('/').pop() || '',
uri: feedUriToHref(feedUri),
}
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
this.rootStore.agent.app.bsky.feed
this.agent.app.bsky.feed
.getFeedGenerator({
feed: feedUri,
})
@@ -234,7 +244,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
try {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
const res = await this.agent.app.bsky.feed.getFeed({
cursor,
limit,
feed: this.feedUri,
+18 -3
View File
@@ -6,12 +6,27 @@ export interface FeedAPIResponse {
}
export interface FeedAPI {
reset(): void
peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse>
}
export interface FeedSourceInfo {
export interface ReasonFeedSource {
$type: 'reasonFeedSource'
uri: string
displayName: string
}
export function isReasonFeedSource(v: unknown): v is ReasonFeedSource {
return (
!!v &&
typeof v === 'object' &&
'$type' in v &&
v.$type === 'reasonFeedSource'
)
}
+10 -38
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,46 +25,19 @@ export interface ExternalEmbedDraft {
localThumb?: ImageModel
}
export async function resolveName(store: RootStoreModel, didOrHandle: string) {
if (!didOrHandle) {
throw new Error('Invalid handle: ""')
}
if (didOrHandle.startsWith('did:')) {
return didOrHandle
}
// we run the resolution always to ensure freshness
const promise = store.agent
.resolveHandle({
handle: didOrHandle,
})
.then(res => {
store.handleResolutions.cache.set(didOrHandle, res.data.did)
return res.data.did
})
// but we can return immediately if it's cached
const cached = store.handleResolutions.cache.get(didOrHandle)
if (cached) {
return cached
}
return promise
}
export async function uploadBlob(
store: RootStoreModel,
agent: BskyAgent,
blob: string,
encoding: string,
): Promise<ComAtprotoRepoUploadBlob.Response> {
if (isWeb) {
// `blob` should be a data uri
return store.agent.uploadBlob(convertDataURIToUint8Array(blob), {
return agent.uploadBlob(convertDataURIToUint8Array(blob), {
encoding,
})
} else {
// `blob` should be a path to a file in the local FS
return store.agent.uploadBlob(
return agent.uploadBlob(
blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding},
)
@@ -81,12 +54,11 @@ interface PostOpts {
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]
knownHandles?: Set<string>
onStateChange?: (state: string) => void
langs?: string[]
}
export async function post(store: RootStoreModel, opts: PostOpts) {
export async function post(agent: BskyAgent, opts: PostOpts) {
let embed:
| AppBskyEmbedImages.Main
| AppBskyEmbedExternal.Main
@@ -102,7 +74,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
)
opts.onStateChange?.('Processing...')
await rt.detectFacets(store.agent)
await rt.detectFacets(agent)
rt = shortenLinks(rt)
// filter out any mention facets that didn't map to a user
@@ -135,7 +107,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
await image.compress()
const path = image.compressed?.path ?? image.path
const {width, height} = image.compressed || image
const res = await uploadBlob(store, path, 'image/jpeg')
const res = await uploadBlob(agent, path, 'image/jpeg')
images.push({
image: res.data.blob,
alt: image.altText ?? '',
@@ -185,7 +157,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
}
if (encoding) {
const thumbUploadRes = await uploadBlob(
store,
agent,
opts.extLink.localThumb.path,
encoding,
)
@@ -224,7 +196,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
// add replyTo if post is a reply to another post
if (opts.replyTo) {
const replyToUrip = new AtUri(opts.replyTo)
const parentPost = await store.agent.getPost({
const parentPost = await agent.getPost({
repo: replyToUrip.host,
rkey: replyToUrip.rkey,
})
@@ -257,7 +229,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
try {
opts.onStateChange?.('Posting...')
return await store.agent.post({
return await agent.post({
text: rt.text,
facets: rt.facets,
reply,
-68
View File
@@ -1,68 +0,0 @@
import {runInAction} from 'mobx'
import {deepObserve} from 'mobx-utils'
import set from 'lodash.set'
const ongoingActions = new Set<any>()
/**
* This is a TypeScript function that optimistically updates data on the client-side before sending a
* request to the server and rolling back changes if the request fails.
* @param {T} model - The object or record that needs to be updated optimistically.
* @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It
* can be used to perform any necessary actions or updates on the model or UI before the server update
* is initiated.
* @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server
* update operation. This function is called after the previous state of the model has been recorded
* and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate`
* function is called with the result
* @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the
* server update is successful. It takes in the response from the server update as its parameter. If
* this parameter is not provided, nothing will happen after the server update.
* @returns A Promise that resolves to `void`.
*/
export const updateDataOptimistically = async <
T extends Record<string, any>,
U,
>(
model: T,
preUpdate: () => void,
serverUpdate: () => Promise<U>,
postUpdate?: (res: U) => void,
): Promise<void> => {
if (ongoingActions.has(model)) {
return
}
ongoingActions.add(model)
const prevState: Map<string, any> = new Map<string, any>()
const dispose = deepObserve(model, (change, path) => {
if (change.observableKind === 'object') {
if (change.type === 'update') {
prevState.set(
[path, change.name].filter(Boolean).join('.'),
change.oldValue,
)
} else if (change.type === 'add') {
prevState.set([path, change.name].filter(Boolean).join('.'), undefined)
}
}
})
preUpdate()
dispose()
try {
const res = await serverUpdate()
runInAction(() => {
postUpdate?.(res)
})
} catch (error) {
runInAction(() => {
prevState.forEach((value, path) => {
set(model, path, value)
})
})
throw error
} finally {
ongoingActions.delete(model)
}
}
@@ -3,4 +3,9 @@ export default class BroadcastChannel {
postMessage(_data: any) {}
close() {}
onmessage: (event: MessageEvent) => void = () => {}
addEventListener(_type: string, _listener: (event: MessageEvent) => void) {}
removeEventListener(
_type: string,
_listener: (event: MessageEvent) => void,
) {}
}
+11 -2
View File
@@ -1,4 +1,10 @@
import {Insets} from 'react-native'
import {Insets, Platform} from 'react-native'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
export const STAGING_SERVICE = 'https://staging.bsky.dev'
export const PROD_SERVICE = 'https://bsky.social'
export const DEFAULT_SERVICE = PROD_SERVICE
const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
@@ -43,7 +49,10 @@ export function IS_PROD(url: string) {
// until open federation, "production" is defined as the main server
// this definition will not work once federation is enabled!
// -prf
return url.startsWith('https://bsky.social')
return (
url.startsWith('https://bsky.social') ||
url.startsWith('https://api.bsky.app')
)
}
export const PROD_TEAM_HANDLES = [
+18 -35
View File
@@ -1,46 +1,29 @@
import {useCallback, useState} from 'react'
import {useStores} from 'state/index'
import {useAnalytics} from 'lib/analytics/analytics'
import {StackActions, useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {AccountData} from 'state/models/session'
import {reset as resetNavigation} from '../../Navigation'
import * as Toast from 'view/com/util/Toast'
import {useSetDrawerOpen} from '#/state/shell/drawer-open'
import {useModalControls} from '#/state/modals'
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'
export function useAccountSwitcher(): [
boolean,
(v: boolean) => void,
(acct: AccountData) => Promise<void>,
] {
export function useAccountSwitcher() {
const {track} = useAnalytics()
const store = useStores()
const setDrawerOpen = useSetDrawerOpen()
const {closeModal} = useModalControls()
const [isSwitching, setIsSwitching] = useState(false)
const navigation = useNavigation<NavigationProp>()
const {selectAccount, clearCurrentAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const onPressSwitchAccount = useCallback(
async (acct: AccountData) => {
async (acct: SessionAccount) => {
track('Settings:SwitchAccountButtonClicked')
setIsSwitching(true)
const success = await store.session.resumeSession(acct)
setDrawerOpen(false)
closeModal()
store.shell.closeAllActiveElements()
if (success) {
resetNavigation()
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
} else {
try {
await selectAccount(acct)
closeAllActiveElements()
Toast.show(`Signed in as ${acct.handle}`)
} catch (e) {
Toast.show('Sorry! We need you to enter your password.')
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
clearCurrentAccount() // back user out to login
}
},
[track, setIsSwitching, navigation, store, setDrawerOpen, closeModal],
[track, clearCurrentAccount, selectAccount, closeAllActiveElements],
)
return [isSwitching, setIsSwitching, onPressSwitchAccount]
return {onPressSwitchAccount}
}
@@ -0,0 +1,15 @@
// Be warned. This Hook is very buggy unless used in a very constrained way.
// To use it safely:
//
// - DO NOT pass its return value as a prop to any user-defined component.
// - DO NOT pass its return value to more than a single component.
//
// In other words, the only safe way to use it is next to the leaf Reanimated View.
//
// Relevant bug reports:
// - https://github.com/software-mansion/react-native-reanimated/issues/5345
// - https://github.com/software-mansion/react-native-reanimated/issues/5360
// - https://github.com/software-mansion/react-native-reanimated/issues/5364
//
// It's great when it works though.
export {useAnimatedScrollHandler} from 'react-native-reanimated'
@@ -0,0 +1,44 @@
import {useRef, useEffect} from 'react'
import {useAnimatedScrollHandler as useAnimatedScrollHandler_BUGGY} from 'react-native-reanimated'
export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = (
config,
deps,
) => {
const ref = useRef(config)
useEffect(() => {
ref.current = config
})
return useAnimatedScrollHandler_BUGGY(
{
onBeginDrag(e) {
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
@@ -1,18 +0,0 @@
import {useEffect, useState} from 'react'
import {useStores} from 'state/index'
import {FeedSourceModel} from 'state/models/content/feed-source'
export function useCustomFeed(uri: string): FeedSourceModel | undefined {
const store = useStores()
const [item, setItem] = useState<FeedSourceModel | undefined>()
useEffect(() => {
async function buildFeedItem() {
const model = new FeedSourceModel(store, uri)
await model.setup()
setItem(model)
}
buildFeedItem()
}, [store, uri])
return item
}
-51
View File
@@ -1,51 +0,0 @@
import {useEffect, useState} from 'react'
import {useStores} from 'state/index'
import isEqual from 'lodash.isequal'
import {AtUri} from '@atproto/api'
import {FeedSourceModel} from 'state/models/content/feed-source'
interface RightNavItem {
uri: string
href: string
hostname: string
collection: string
rkey: string
displayName: string
}
export function useDesktopRightNavItems(uris: string[]): RightNavItem[] {
const store = useStores()
const [items, setItems] = useState<RightNavItem[]>([])
const [lastUris, setLastUris] = useState<string[]>([])
useEffect(() => {
if (isEqual(uris, lastUris)) {
// no changes
return
}
async function fetchFeedInfo() {
const models = uris
.slice(0, 25)
.map(uri => new FeedSourceModel(store, uri))
await Promise.all(models.map(m => m.setup()))
setItems(
models.map(model => {
const {hostname, collection, rkey} = new AtUri(model.uri)
return {
uri: model.uri,
href: model.href,
hostname,
collection,
rkey,
displayName: model.displayName,
}
}),
)
setLastUris(uris)
}
fetchFeedInfo()
}, [store, uris, lastUris, setLastUris, setItems])
return items
}
-55
View File
@@ -1,55 +0,0 @@
import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {useStores} from 'state/index'
import {FollowState} from 'state/models/cache/my-follows'
import {logger} from '#/logger'
export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
const store = useStores()
const state = store.me.follows.getFollowState(profile.did)
return {
state,
following: state === FollowState.Following,
toggle: React.useCallback(async () => {
if (state === FollowState.Following) {
try {
await store.agent.deleteFollow(
store.me.follows.getFollowUri(profile.did),
)
store.me.follows.removeFollow(profile.did)
return {
state: FollowState.NotFollowing,
following: false,
}
} catch (e: any) {
logger.error('Failed to delete follow', {error: e})
throw e
}
} else if (state === FollowState.NotFollowing) {
try {
const res = await store.agent.follow(profile.did)
store.me.follows.addFollow(profile.did, {
followRecordUri: res.uri,
did: profile.did,
handle: profile.handle,
displayName: profile.displayName,
avatar: profile.avatar,
})
return {
state: FollowState.Following,
following: true,
}
} catch (e: any) {
logger.error('Failed to create follow', {error: e})
throw e
}
}
return {
state: FollowState.Unknown,
following: false,
}
}, [store, profile, state]),
}
}
-29
View File
@@ -1,29 +0,0 @@
import {useEffect, useState} from 'react'
import {useStores} from 'state/index'
import isEqual from 'lodash.isequal'
import {FeedSourceModel} from 'state/models/content/feed-source'
export function useHomeTabs(uris: string[]): string[] {
const store = useStores()
const [tabs, setTabs] = useState<string[]>(['Following'])
const [lastUris, setLastUris] = useState<string[]>([])
useEffect(() => {
if (isEqual(uris, lastUris)) {
// no changes
return
}
async function fetchFeedInfo() {
const models = uris
.slice(0, 25)
.map(uri => new FeedSourceModel(store, uri))
await Promise.all(models.map(m => m.setup()))
setTabs(['Following'].concat(models.map(f => f.displayName)))
setLastUris(uris)
}
fetchFeedInfo()
}, [store, uris, lastUris, setLastUris, setTabs])
return tabs
}
+18 -35
View File
@@ -1,60 +1,43 @@
import React from 'react'
import {autorun} from 'mobx'
import {
Easing,
interpolate,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout'
export function useMinimalShellMode() {
const minimalShellMode = useMinimalShellModeState()
const minimalShellInterp = useSharedValue(0)
const mode = useMinimalShellModeState()
const {footerHeight, headerHeight} = useShellLayout()
const footerMinimalShellTransform = useAnimatedStyle(() => {
return {
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2),
transform: [
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])},
{
translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
},
],
}
})
const headerMinimalShellTransform = useAnimatedStyle(() => {
return {
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2),
transform: [
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])},
{
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
},
],
}
})
const fabMinimalShellTransform = useAnimatedStyle(() => {
return {
transform: [
{translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])},
{
translateY: interpolate(mode.value, [0, 1], [-44, 0]),
},
],
}
})
React.useEffect(() => {
return autorun(() => {
if (minimalShellMode) {
minimalShellInterp.value = withTiming(1, {
duration: 125,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
})
} else {
minimalShellInterp.value = withTiming(0, {
duration: 125,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
})
}
})
}, [minimalShellInterp, minimalShellMode])
return {
minimalShellMode,
footerMinimalShellTransform,
headerMinimalShellTransform,
fabMinimalShellTransform,
+23
View File
@@ -0,0 +1,23 @@
import {useCallback, useInsertionEffect, useRef} from 'react'
// This should be used sparingly. It erases reactivity, i.e. when the inputs
// change, the function itself will remain the same. This means that if you
// use this at a higher level of your tree, and then some state you read in it
// changes, there is no mechanism for anything below in the tree to "react"
// to this change (e.g. by knowing to call your function again).
//
// Also, you should avoid calling the returned function during rendering
// since the values captured by it are going to lag behind.
export function useNonReactiveCallback<T extends Function>(fn: T): T {
const ref = useRef(fn)
useInsertionEffect(() => {
ref.current = fn
}, [fn])
return useCallback(
(...args: any) => {
const latestFn = ref.current
return latestFn(...args)
},
[ref],
) as unknown as T
}
+108 -52
View File
@@ -1,69 +1,125 @@
import {useState, useCallback, useRef} from 'react'
import {useState, useCallback, useMemo} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {s} from 'lib/styles'
import {useWebMediaQueries} from './useWebMediaQueries'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {
useSharedValue,
interpolate,
runOnJS,
ScrollHandlers,
} from 'react-native-reanimated'
const Y_LIMIT = 10
const useDeviceLimits = () => {
const {isDesktop} = useWebMediaQueries()
return {
dyLimitUp: isDesktop ? 30 : 10,
dyLimitDown: isDesktop ? 150 : 10,
}
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
}
export type OnScrollCb = (
event: NativeSyntheticEvent<NativeScrollEvent>,
) => void
export type OnScrollHandler = ScrollHandlers<any>
export type ResetCb = () => void
export function useOnMainScroll(): [OnScrollCb, boolean, ResetCb] {
let lastY = useRef(0)
let [isScrolledDown, setIsScrolledDown] = useState(false)
const {dyLimitUp, dyLimitDown} = useDeviceLimits()
const minimalShellMode = useMinimalShellMode()
const setMinimalShellMode = useSetMinimalShellMode()
export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] {
const {headerHeight} = useShellLayout()
const [isScrolledDown, setIsScrolledDown] = useState(false)
const mode = useMinimalShellMode()
const setMode = useSetMinimalShellMode()
const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null)
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],
)
return [
useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y
const dy = y - (lastY.current || 0)
lastY.current = y
if (!minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
setMinimalShellMode(true)
} else if (minimalShellMode && (dy < dyLimitUp * -1 || y <= Y_LIMIT)) {
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)
}
},
[
dyLimitDown,
dyLimitUp,
isScrolledDown,
minimalShellMode,
setMinimalShellMode,
],
),
scrollHandler,
isScrolledDown,
useCallback(() => {
setIsScrolledDown(false)
setMinimalShellMode(false)
lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
}, [setIsScrolledDown, setMinimalShellMode]),
setMode(false)
}, [setMode]),
]
}
+4 -8
View File
@@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings'
import {useStores} from 'state/index'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
/**
* Requires consuming component to be wrapped in `observer`:
* https://stackoverflow.com/a/71488009
*/
export function useSetTitle(title?: string) {
const navigation = useNavigation<NavigationProp>()
const {unreadCountLabel} = useStores().me.notifications
const numUnread = useUnreadNotifications()
useEffect(() => {
if (title) {
navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
navigation.setOptions({title: bskyTitle(title, numUnread)})
}
}, [title, navigation, unreadCountLabel])
}, [title, navigation, numUnread])
}
+98
View File
@@ -0,0 +1,98 @@
import {useState, useRef, useEffect, useCallback} from 'react'
type Task<TServerState> = {
isOn: boolean
resolve: (serverState: TServerState) => void
reject: (e: unknown) => void
}
type TaskQueue<TServerState> = {
activeTask: Task<TServerState> | null
queuedTask: Task<TServerState> | null
}
function AbortError() {
const e = new Error()
e.name = 'AbortError'
return e
}
export function useToggleMutationQueue<TServerState>({
initialState,
runMutation,
onSuccess,
}: {
initialState: TServerState
runMutation: (
prevState: TServerState,
nextIsOn: boolean,
) => Promise<TServerState>
onSuccess: (finalState: TServerState) => void
}) {
// We use the queue as a mutable object.
// This is safe becuase it is not used for rendering.
const [queue] = useState<TaskQueue<TServerState>>({
activeTask: null,
queuedTask: null,
})
async function processQueue() {
if (queue.activeTask) {
// There is another active processQueue call iterating over tasks.
// It will handle any newly added tasks, so we should exit early.
return
}
// To avoid relying on the rendered state, capture it once at the start.
// From that point on, and until the queue is drained, we'll use the real server state.
let confirmedState: TServerState = initialState
try {
while (queue.queuedTask) {
const prevTask = queue.activeTask
const nextTask = queue.queuedTask
queue.activeTask = nextTask
queue.queuedTask = null
if (prevTask?.isOn === nextTask.isOn) {
// Skip multiple requests to update to the same value in a row.
prevTask.reject(new (AbortError as any)())
continue
}
try {
// The state received from the server feeds into the next task.
// This lets us queue deletions of not-yet-created resources.
confirmedState = await runMutation(confirmedState, nextTask.isOn)
nextTask.resolve(confirmedState)
} catch (e) {
nextTask.reject(e)
}
}
} finally {
onSuccess(confirmedState)
queue.activeTask = null
queue.queuedTask = null
}
}
function queueToggle(isOn: boolean): Promise<TServerState> {
return new Promise((resolve, reject) => {
// This is a toggle, so the next queued value can safely replace the queued one.
if (queue.queuedTask) {
queue.queuedTask.reject(new (AbortError as any)())
}
queue.queuedTask = {isOn, resolve, reject}
processQueue()
})
}
const queueToggleRef = useRef(queueToggle)
useEffect(() => {
queueToggleRef.current = queueToggle
})
const queueToggleStable = useCallback(
(isOn: boolean): Promise<TServerState> => {
const queueToggleLatest = queueToggleRef.current
return queueToggleLatest(isOn)
},
[],
)
return queueToggleStable
}
-89
View File
@@ -1,89 +0,0 @@
import {LabelPreferencesModel} from 'state/models/ui/preferences'
import {LabelValGroup} from './types'
export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
id: 'illegal',
title: 'Illegal Content',
warning: 'Illegal Content',
values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
}
export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = {
id: 'always-filter',
title: 'Content Warning',
warning: 'Content Warning',
values: ['!filter'],
}
export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = {
id: 'always-warn',
title: 'Content Warning',
warning: 'Content Warning',
values: ['!warn', 'account-security'],
}
export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
id: 'unknown',
title: 'Unknown Label',
warning: 'Content Warning',
values: [],
}
export const CONFIGURABLE_LABEL_GROUPS: Record<
keyof LabelPreferencesModel,
LabelValGroup
> = {
nsfw: {
id: 'nsfw',
title: 'Explicit Sexual Images',
subtitle: 'i.e. pornography',
warning: 'Sexually Explicit',
values: ['porn', 'nsfl'],
isAdultImagery: true,
},
nudity: {
id: 'nudity',
title: 'Other Nudity',
subtitle: 'Including non-sexual and artistic',
warning: 'Nudity',
values: ['nudity'],
isAdultImagery: true,
},
suggestive: {
id: 'suggestive',
title: 'Sexually Suggestive',
subtitle: 'Does not include nudity',
warning: 'Sexually Suggestive',
values: ['sexual'],
isAdultImagery: true,
},
gore: {
id: 'gore',
title: 'Violent / Bloody',
subtitle: 'Gore, self-harm, torture',
warning: 'Violence',
values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
isAdultImagery: true,
},
hate: {
id: 'hate',
title: 'Hate Group Iconography',
subtitle: 'Images of terror groups, articles covering events, etc.',
warning: 'Hate Groups',
values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
},
spam: {
id: 'spam',
title: 'Spam',
subtitle: 'Excessive unwanted interactions',
warning: 'Spam',
values: ['spam'],
},
impersonation: {
id: 'impersonation',
title: 'Impersonation',
subtitle: 'Accounts falsely claiming to be people or orgs',
warning: 'Impersonation',
values: ['impersonation'],
},
}
-18
View File
@@ -1,18 +0,0 @@
import {ComAtprotoLabelDefs} from '@atproto/api'
import {LabelPreferencesModel} from 'state/models/ui/preferences'
export type Label = ComAtprotoLabelDefs.Label
export interface LabelValGroup {
id:
| keyof LabelPreferencesModel
| 'illegal'
| 'always-filter'
| 'always-warn'
| 'unknown'
title: string
isAdultImagery?: boolean
subtitle?: string
warning: string
values: string[]
}
+16 -24
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 {RootStoreModel} from 'state/index'
import {PostThreadModel} from 'state/models/content/post-thread'
import {ComposerOptsQuote} from 'state/models/ui/shell'
import {ComposerOptsQuote} from 'state/shell/composer'
import {useGetPost} from '#/state/queries/post'
// TODO
// import {Home} from 'view/screens/Home'
@@ -22,7 +22,7 @@ import {ComposerOptsQuote} from 'state/models/ui/shell'
// remove once that's implemented
// -prf
export async function extractBskyMeta(
store: RootStoreModel,
agent: BskyAgent,
url: string,
): Promise<LinkMeta> {
url = convertBskyAppUrlIfNeeded(url)
@@ -102,38 +102,30 @@ export async function extractBskyMeta(
}
export async function getPostAsQuote(
store: RootStoreModel,
getPost: ReturnType<typeof useGetPost>,
url: string,
): Promise<ComposerOptsQuote> {
url = convertBskyAppUrlIfNeeded(url)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const threadUri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
const threadView = new PostThreadModel(store, {
uri: threadUri,
depth: 0,
})
await threadView.setup()
if (!threadView.thread || threadView.notFound) {
throw new Error('Not found')
}
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
const post = await getPost({uri: uri})
return {
uri: threadView.thread.post.uri,
cid: threadView.thread.post.cid,
text: threadView.thread.postRecord?.text || '',
indexedAt: threadView.thread.post.indexedAt,
author: threadView.thread.post.author,
uri: post.uri,
cid: post.cid,
text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '',
indexedAt: post.indexedAt,
author: post.author,
}
}
export async function getFeedAsEmbed(
store: RootStoreModel,
agent: BskyAgent,
url: string,
): Promise<apilib.ExternalEmbedDraft> {
url = convertBskyAppUrlIfNeeded(url)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey)
const res = await store.agent.app.bsky.feed.getFeedGenerator({feed})
const res = await agent.app.bsky.feed.getFeedGenerator({feed})
return {
isLoading: false,
uri: feed,
@@ -153,13 +145,13 @@ export async function getFeedAsEmbed(
}
export async function getListAsEmbed(
store: RootStoreModel,
agent: BskyAgent,
url: string,
): Promise<apilib.ExternalEmbedDraft> {
url = convertBskyAppUrlIfNeeded(url)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const list = makeRecordUri(user, 'app.bsky.graph.list', rkey)
const res = await store.agent.app.bsky.graph.getList({list})
const res = await agent.app.bsky.graph.getList({list})
return {
isLoading: false,
uri: list,
+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(
store: RootStoreModel,
agent: BskyAgent,
url: string,
timeout = 5e3,
): Promise<LinkMeta> {
if (isBskyAppUrl(url)) {
return extractBskyMeta(store, url)
return extractBskyMeta(agent, url)
}
let urlp
@@ -55,9 +55,9 @@ export async function getLinkMeta(
const to = setTimeout(() => controller.abort(), timeout || 5e3)
const response = await fetch(
`${LINK_META_PROXY(
store.session.currentSession?.service || '',
)}${encodeURIComponent(url)}`,
`${LINK_META_PROXY(agent.service.toString() || '')}${encodeURIComponent(
url,
)}`,
{signal: controller.signal},
)
+34
View File
@@ -0,0 +1,34 @@
import {Image} from 'react-native'
import type {Dimensions} from 'lib/media/types'
const sizes: Map<string, Dimensions> = new Map()
const activeRequests: Map<string, Promise<Dimensions>> = new Map()
export function get(uri: string): Dimensions | undefined {
return sizes.get(uri)
}
export async function fetch(uri: string): Promise<Dimensions> {
const Dimensions = sizes.get(uri)
if (Dimensions) {
return Dimensions
}
const prom =
activeRequests.get(uri) ||
new Promise<Dimensions>(resolve => {
Image.getSize(
uri,
(width: number, height: number) => resolve({width, height}),
(err: any) => {
console.error('Failed to fetch image dimensions for', uri, err)
resolve({width: 0, height: 0})
},
)
})
activeRequests.set(uri, prom)
const res = await prom
activeRequests.delete(uri)
sizes.set(uri, res)
return res
}
+3 -7
View File
@@ -1,4 +1,3 @@
import {RootStoreModel} from 'state/index'
import {Image as RNImage} from 'react-native-image-crop-picker'
import RNFS from 'react-native-fs'
import {CropperOptions} from './types'
@@ -22,18 +21,15 @@ async function getFile() {
})
}
export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> {
export async function openPicker(): Promise<RNImage[]> {
return [await getFile()]
}
export async function openCamera(_store: RootStoreModel): Promise<RNImage> {
export async function openCamera(): Promise<RNImage> {
return await getFile()
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOptions,
): Promise<RNImage> {
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
return {
path: opts.path,
mime: 'image/jpeg',
+2 -18
View File
@@ -3,23 +3,10 @@ import {
openCropper as openCropperFn,
Image as RNImage,
} from 'react-native-image-crop-picker'
import {RootStoreModel} from 'state/index'
import {CameraOpts, CropperOptions} from './types'
export {openPicker} from './picker.shared'
/**
* NOTE
* These methods all include the RootStoreModel as the first param
* because the web versions require it. The signatures have to remain
* equivalent between the different forms, but the store param is not
* used here.
* -prf
*/
export async function openCamera(
_store: RootStoreModel,
opts: CameraOpts,
): Promise<RNImage> {
export async function openCamera(opts: CameraOpts): Promise<RNImage> {
const item = await openCameraFn({
width: opts.width,
height: opts.height,
@@ -39,10 +26,7 @@ export async function openCamera(
}
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOptions,
) {
export async function openCropper(opts: CropperOptions) {
const item = await openCropperFn({
...opts,
forceJpg: true, // ios only
+2 -9
View File
@@ -1,23 +1,16 @@
/// <reference lib="dom" />
import {CameraOpts, CropperOptions} from './types'
import {RootStoreModel} from 'state/index'
import {Image as RNImage} from 'react-native-image-crop-picker'
export {openPicker} from './picker.shared'
import {unstable__openModal} from '#/state/modals'
export async function openCamera(
_store: RootStoreModel,
_opts: CameraOpts,
): Promise<RNImage> {
export async function openCamera(_opts: CameraOpts): Promise<RNImage> {
// const mediaType = opts.mediaType || 'photo' TODO
throw new Error('TODO')
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOptions,
): Promise<RNImage> {
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
// TODO handle more opts
return new Promise((resolve, reject) => {
unstable__openModal({
+11 -11
View File
@@ -1,19 +1,19 @@
import * as Notifications from 'expo-notifications'
import {RootStoreModel} from '../../state'
import {QueryClient} from '@tanstack/react-query'
import {resetToTab} from '../../Navigation'
import {devicePlatform, isIOS} from 'platform/detection'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
import {listenSessionLoaded} from '#/state/events'
const SERVICE_DID = (serviceUrl?: string) =>
serviceUrl?.includes('staging')
? 'did:web:api.staging.bsky.dev'
: 'did:web:api.bsky.app'
export function init(store: RootStoreModel) {
store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
store.onSessionLoaded(async () => {
export function init(queryClient: QueryClient) {
listenSessionLoaded(async (account, agent) => {
// request notifications permission once the user has logged in
const perms = await Notifications.getPermissionsAsync()
if (!perms.granted) {
@@ -24,8 +24,8 @@ export function init(store: RootStoreModel) {
const token = await getPushToken()
if (token) {
try {
await store.agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(store.session.data?.service),
await agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(account.service),
platform: devicePlatform,
token: token.data,
appId: 'xyz.blueskyweb.app',
@@ -53,8 +53,8 @@ export function init(store: RootStoreModel) {
)
if (t) {
try {
await store.agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(store.session.data?.service),
await agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(account.service),
platform: devicePlatform,
token: t,
appId: 'xyz.blueskyweb.app',
@@ -83,7 +83,7 @@ export function init(store: RootStoreModel) {
)
if (event.request.trigger.type === 'push') {
// refresh notifications in the background
store.me.notifications.syncQueue()
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
// handle payload-based deeplinks
let payload
if (isIOS) {
@@ -121,7 +121,7 @@ export function init(store: RootStoreModel) {
logger.DebugContext.notifications,
)
track('Notificatons:OpenApp')
store.me.notifications.refresh() // refresh notifications
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
resetToTab('NotificationsTab') // open notifications tab
}
},
+11 -1
View File
@@ -1,3 +1,13 @@
import {QueryClient} from '@tanstack/react-query'
export const queryClient = new QueryClient()
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// NOTE
// refetchOnWindowFocus breaks some UIs (like feeds)
// so we NEVER want to enable this
// -prf
refetchOnWindowFocus: false,
},
},
})
+1 -1
View File
@@ -1,5 +1,5 @@
import {AtUri} from '@atproto/api'
import {PROD_SERVICE} from 'state/index'
import {PROD_SERVICE} from 'lib/constants'
import TLDs from 'tlds'
import psl from 'psl'
+20
View File
@@ -0,0 +1,20 @@
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
+101
View File
@@ -0,0 +1,101 @@
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
@@ -0,0 +1,99 @@
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
@@ -0,0 +1 @@
export type Shadow<T> = T & {isShadowed: true}
+38
View File
@@ -0,0 +1,38 @@
import EventEmitter from 'eventemitter3'
import {BskyAgent} from '@atproto/api'
import {SessionAccount} from './session'
type UnlistenFn = () => void
const emitter = new EventEmitter()
// a "soft reset" typically means scrolling to top and loading latest
// but it can depend on the screen
export function emitSoftReset() {
emitter.emit('soft-reset')
}
export function listenSoftReset(fn: () => void): UnlistenFn {
emitter.on('soft-reset', fn)
return () => emitter.off('soft-reset', fn)
}
export function emitSessionLoaded(
sessionAccount: SessionAccount,
agent: BskyAgent,
) {
emitter.emit('session-loaded', sessionAccount, agent)
}
export function listenSessionLoaded(
fn: (sessionAccount: SessionAccount, agent: BskyAgent) => void,
): UnlistenFn {
emitter.on('session-loaded', fn)
return () => emitter.off('session-loaded', fn)
}
export function emitSessionDropped() {
emitter.emit('session-dropped')
}
export function listenSessionDropped(fn: () => void): UnlistenFn {
emitter.on('session-dropped', fn)
return () => emitter.off('session-dropped', fn)
}
-54
View File
@@ -1,54 +0,0 @@
import {autorun} from 'mobx'
import {AppState, Platform} from 'react-native'
import {BskyAgent} from '@atproto/api'
import {RootStoreModel} from './models/root-store'
import * as apiPolyfill from 'lib/api/api-polyfill'
import * as storage from 'lib/storage'
import {logger} from '#/logger'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
export const STAGING_SERVICE = 'https://staging.bsky.dev'
export const PROD_SERVICE = 'https://bsky.social'
export const DEFAULT_SERVICE = PROD_SERVICE
const ROOT_STATE_STORAGE_KEY = 'root'
const STATE_FETCH_INTERVAL = 15e3
export async function setupState(serviceUri = DEFAULT_SERVICE) {
let rootStore: RootStoreModel
let data: any
apiPolyfill.doPolyfill()
rootStore = new RootStoreModel(new BskyAgent({service: serviceUri}))
try {
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
logger.debug('Initial hydrate', {hasSession: !!data.session})
rootStore.hydrate(data)
} catch (e: any) {
logger.error('Failed to load state from storage', {error: e})
}
rootStore.attemptSessionResumption()
// track changes & save to storage
autorun(() => {
const snapshot = rootStore.serialize()
storage.save(ROOT_STATE_STORAGE_KEY, snapshot)
})
// periodic state fetch
setInterval(() => {
// NOTE
// this must ONLY occur when the app is active, as the bg-fetch handler
// will wake up the thread and cause this interval to fire, which in
// turn schedules a bunch of work at a poor time
// -prf
if (AppState.currentState === 'active') {
rootStore.updateSessionState()
}
}, STATE_FETCH_INTERVAL)
return rootStore
}
export {useStores, RootStoreModel, RootStoreProvider} from './models/root-store'
+86
View File
@@ -0,0 +1,86 @@
import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
interface Lightbox {
name: string
}
export class ProfileImageLightbox implements Lightbox {
name = 'profile-image'
constructor(public profile: AppBskyActorDefs.ProfileViewDetailed) {}
}
interface ImagesLightboxItem {
uri: string
alt?: string
}
export class ImagesLightbox implements Lightbox {
name = 'images'
constructor(public images: ImagesLightboxItem[], public index: number) {}
setIndex(index: number) {
this.index = index
}
}
const LightboxContext = React.createContext<{
activeLightbox: Lightbox | null
}>({
activeLightbox: null,
})
const LightboxControlContext = React.createContext<{
openLightbox: (lightbox: Lightbox) => void
closeLightbox: () => boolean
}>({
openLightbox: () => {},
closeLightbox: () => false,
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const [activeLightbox, setActiveLightbox] = React.useState<Lightbox | null>(
null,
)
const openLightbox = useNonReactiveCallback((lightbox: Lightbox) => {
setActiveLightbox(lightbox)
})
const closeLightbox = useNonReactiveCallback(() => {
let wasActive = !!activeLightbox
setActiveLightbox(null)
return wasActive
})
const state = React.useMemo(
() => ({
activeLightbox,
}),
[activeLightbox],
)
const methods = React.useMemo(
() => ({
openLightbox,
closeLightbox,
}),
[openLightbox, closeLightbox],
)
return (
<LightboxContext.Provider value={state}>
<LightboxControlContext.Provider value={methods}>
{children}
</LightboxControlContext.Provider>
</LightboxContext.Provider>
)
}
export function useLightbox() {
return React.useContext(LightboxContext)
}
export function useLightboxControls() {
return React.useContext(LightboxControlContext)
}
+44 -35
View File
@@ -1,12 +1,11 @@
import React from 'react'
import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
import {StyleProp, ViewStyle, DeviceEventEmitter} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
import {StyleProp, ViewStyle} from 'react-native'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {ProfileModel} from '#/state/models/content/profile'
import {ImageModel} from '#/state/models/media/image'
import {ListModel} from '#/state/models/content/list'
import {GalleryModel} from '#/state/models/media/gallery'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
export interface ConfirmModal {
name: 'confirm'
@@ -21,7 +20,7 @@ export interface ConfirmModal {
export interface EditProfileModal {
name: 'edit-profile'
profileView: ProfileModel
profile: AppBskyActorDefs.ProfileViewDetailed
onUpdate?: () => void
}
@@ -55,7 +54,7 @@ export type ReportModal = {
export interface CreateOrEditListModal {
name: 'create-or-edit-list'
purpose?: string
list?: ListModel
list?: AppBskyGraphDefs.ListView
onSave?: (uri: string) => void
}
@@ -67,10 +66,13 @@ export interface UserAddRemoveListsModal {
onRemove?: (listUri: string) => void
}
export interface ListAddUserModal {
name: 'list-add-user'
list: ListModel
onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
export interface ListAddRemoveUsersModal {
name: 'list-add-remove-users'
list: AppBskyGraphDefs.ListView
onChange?: (
type: 'add' | 'remove',
profile: AppBskyActorDefs.ProfileViewBasic,
) => void
}
export interface EditImageModal {
@@ -184,7 +186,7 @@ export type Modal =
// Lists
| CreateOrEditListModal
| UserAddRemoveListsModal
| ListAddUserModal
| ListAddRemoveUsersModal
// Posts
| AltTextImageModal
@@ -212,10 +214,12 @@ const ModalContext = React.createContext<{
const ModalControlContext = React.createContext<{
openModal: (modal: Modal) => void
closeModal: () => void
closeModal: () => boolean
closeAllModals: () => void
}>({
openModal: () => {},
closeModal: () => {},
closeModal: () => false,
closeAllModals: () => {},
})
/**
@@ -225,45 +229,50 @@ 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 [isModalActive, setIsModalActive] = React.useState(false)
const [activeModals, setActiveModals] = React.useState<Modal[]>([])
const openModal = React.useCallback(
(modal: Modal) => {
DeviceEventEmitter.emit('navigation')
setActiveModals(activeModals => [...activeModals, modal])
setIsModalActive(true)
},
[setIsModalActive, setActiveModals],
)
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
const closeModal = React.useCallback(() => {
let totalActiveModals = 0
setActiveModals(activeModals => {
activeModals.pop()
totalActiveModals = activeModals.length
return activeModals
})
setIsModalActive(totalActiveModals > 0)
}, [setIsModalActive, setActiveModals])
unstable__closeModal = closeModal
const state = React.useMemo(
() => ({
isModalActive,
isModalActive: activeModals.length > 0,
activeModals,
}),
[isModalActive, activeModals],
[activeModals],
)
const methods = React.useMemo(
() => ({
openModal,
closeModal,
closeAllModals,
}),
[openModal, closeModal],
[openModal, closeModal, closeAllModals],
)
return (
-5
View File
@@ -1,5 +0,0 @@
import {LRUMap} from 'lru_map'
export class HandleResolutionsCache {
cache: LRUMap<string, string> = new LRUMap(500)
}
-38
View File
@@ -1,38 +0,0 @@
import {Image} from 'react-native'
import type {Dimensions} from 'lib/media/types'
export class ImageSizesCache {
sizes: Map<string, Dimensions> = new Map()
activeRequests: Map<string, Promise<Dimensions>> = new Map()
constructor() {}
get(uri: string): Dimensions | undefined {
return this.sizes.get(uri)
}
async fetch(uri: string): Promise<Dimensions> {
const Dimensions = this.sizes.get(uri)
if (Dimensions) {
return Dimensions
}
const prom =
this.activeRequests.get(uri) ||
new Promise<Dimensions>(resolve => {
Image.getSize(
uri,
(width: number, height: number) => resolve({width, height}),
(err: any) => {
console.error('Failed to fetch image dimensions for', uri, err)
resolve({width: 0, height: 0})
},
)
})
this.activeRequests.set(uri, prom)
const res = await prom
this.activeRequests.delete(uri)
this.sizes.set(uri, res)
return res
}
}
-44
View File
@@ -1,44 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {LRUMap} from 'lru_map'
import {RootStoreModel} from '../root-store'
import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta'
type CacheValue = Promise<LinkMeta> | LinkMeta
export class LinkMetasCache {
cache: LRUMap<string, CacheValue> = new LRUMap(100)
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
cache: false,
},
{autoBind: true},
)
}
// public api
// =
async getLinkMeta(url: string) {
const cached = this.cache.get(url)
if (cached) {
try {
return await cached
} catch (e) {
// ignore, we'll try again
}
}
try {
const promise = getLinkMeta(this.rootStore, url)
this.cache.set(url, promise)
const res = await promise
this.cache.set(url, res)
return res
} catch (e) {
this.cache.delete(url)
throw e
}
}
}
-137
View File
@@ -1,137 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyActorDefs,
AppBskyGraphGetFollows as GetFollows,
moderateProfile,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
const MAX_SYNC_PAGES = 10
const SYNC_TTL = 60e3 * 10 // 10 minutes
type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
export enum FollowState {
Following,
NotFollowing,
Unknown,
}
export interface FollowInfo {
did: string
followRecordUri: string | undefined
handle: string
displayName: string | undefined
avatar: string | undefined
}
/**
* This model is used to maintain a synced local cache of the user's
* follows. It should be periodically refreshed and updated any time
* the user makes a change to their follows.
*/
export class MyFollowsCache {
// data
byDid: Record<string, FollowInfo> = {}
lastSync = 0
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
// public api
// =
clear() {
this.byDid = {}
}
/**
* Syncs a subset of the user's follows
* for performance reasons, caps out at 1000 follows
*/
syncIfNeeded = bundleAsync(async () => {
if (this.lastSync > Date.now() - SYNC_TTL) {
return
}
let cursor
for (let i = 0; i < MAX_SYNC_PAGES; i++) {
const res: GetFollows.Response = await this.rootStore.agent.getFollows({
actor: this.rootStore.me.did,
cursor,
limit: 100,
})
res.data.follows = res.data.follows.filter(
profile =>
!moderateProfile(profile, this.rootStore.preferences.moderationOpts)
.account.filter,
)
this.hydrateMany(res.data.follows)
if (!res.data.cursor) {
break
}
cursor = res.data.cursor
}
this.lastSync = Date.now()
})
getFollowState(did: string): FollowState {
if (typeof this.byDid[did] === 'undefined') {
return FollowState.Unknown
}
if (typeof this.byDid[did].followRecordUri === 'string') {
return FollowState.Following
}
return FollowState.NotFollowing
}
async fetchFollowState(did: string): Promise<FollowState> {
// TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf
const res = await this.rootStore.agent.getProfile({actor: did})
this.hydrate(did, res.data)
return this.getFollowState(did)
}
getFollowUri(did: string): string {
const v = this.byDid[did]
if (v && typeof v.followRecordUri === 'string') {
return v.followRecordUri
}
throw new Error('Not a followed user')
}
addFollow(did: string, info: FollowInfo) {
this.byDid[did] = info
}
removeFollow(did: string) {
if (this.byDid[did]) {
this.byDid[did].followRecordUri = undefined
}
}
hydrate(did: string, profile: Profile) {
this.byDid[did] = {
did,
followRecordUri: profile.viewer?.following,
handle: profile.handle,
displayName: profile.displayName,
avatar: profile.avatar,
}
}
hydrateMany(profiles: Profile[]) {
for (const profile of profiles) {
this.hydrate(profile.did, profile)
}
}
}
-70
View File
@@ -1,70 +0,0 @@
import {LRUMap} from 'lru_map'
import {RootStoreModel} from '../root-store'
import {
AppBskyFeedDefs,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedPost,
} from '@atproto/api'
type PostView = AppBskyFeedDefs.PostView
export class PostsCache {
cache: LRUMap<string, PostView> = new LRUMap(500)
constructor(public rootStore: RootStoreModel) {}
set(uri: string, postView: PostView) {
this.cache.set(uri, postView)
if (postView.author.handle) {
this.rootStore.handleResolutions.cache.set(
postView.author.handle,
postView.author.did,
)
}
}
fromFeedItem(feedItem: AppBskyFeedDefs.FeedViewPost) {
this.set(feedItem.post.uri, feedItem.post)
if (
feedItem.reply?.parent &&
AppBskyFeedDefs.isPostView(feedItem.reply?.parent)
) {
this.set(feedItem.reply.parent.uri, feedItem.reply.parent)
}
const embed = feedItem.post.embed
if (
AppBskyEmbedRecord.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record) &&
AppBskyFeedPost.isRecord(embed.record.value) &&
AppBskyFeedPost.validateRecord(embed.record.value).success
) {
this.set(embed.record.uri, embedViewToPostView(embed.record))
}
if (
AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record?.record) &&
AppBskyFeedPost.isRecord(embed.record.record.value) &&
AppBskyFeedPost.validateRecord(embed.record.record.value).success
) {
this.set(
embed.record.record.uri,
embedViewToPostView(embed.record.record),
)
}
}
}
function embedViewToPostView(
embedView: AppBskyEmbedRecord.ViewRecord,
): PostView {
return {
$type: 'app.bsky.feed.post#view',
uri: embedView.uri,
cid: embedView.cid,
author: embedView.author,
record: embedView.value,
indexedAt: embedView.indexedAt,
labels: embedView.labels,
}
}
-50
View File
@@ -1,50 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {LRUMap} from 'lru_map'
import {RootStoreModel} from '../root-store'
import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
type CacheValue = Promise<GetProfile.Response> | GetProfile.Response
export class ProfilesCache {
cache: LRUMap<string, CacheValue> = new LRUMap(100)
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
cache: false,
},
{autoBind: true},
)
}
// public api
// =
async getProfile(did: string) {
const cached = this.cache.get(did)
if (cached) {
try {
return await cached
} catch (e) {
// ignore, we'll try again
}
}
try {
const promise = this.rootStore.agent.getProfile({
actor: did,
})
this.cache.set(did, promise)
const res = await promise
this.cache.set(did, res)
return res
} catch (e) {
this.cache.delete(did)
throw e
}
}
overwrite(did: string, res: GetProfile.Response) {
this.cache.set(did, res)
}
}
-231
View File
@@ -1,231 +0,0 @@
import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from 'state/models/root-store'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
export class FeedSourceModel {
// state
_reactKey: string
hasLoaded = false
error: string | undefined
// data
uri: string
cid: string = ''
type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported'
avatar: string | undefined = ''
displayName: string = ''
descriptionRT: RichText | null = null
creatorDid: string = ''
creatorHandle: string = ''
likeCount: number | undefined = 0
likeUri: string | undefined = ''
constructor(public rootStore: RootStoreModel, uri: string) {
this._reactKey = uri
this.uri = uri
try {
const urip = new AtUri(uri)
if (urip.collection === 'app.bsky.feed.generator') {
this.type = 'feed-generator'
} else if (urip.collection === 'app.bsky.graph.list') {
this.type = 'list'
}
} catch {}
this.displayName = uri.split('/').pop() || ''
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get href() {
const urip = new AtUri(this.uri)
const collection =
urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
return `/profile/${urip.hostname}/${collection}/${urip.rkey}`
}
get isSaved() {
return this.rootStore.preferences.savedFeeds.includes(this.uri)
}
get isPinned() {
return this.rootStore.preferences.isPinnedFeed(this.uri)
}
get isLiked() {
return !!this.likeUri
}
get isOwner() {
return this.creatorDid === this.rootStore.me.did
}
setup = bundleAsync(async () => {
try {
if (this.type === 'feed-generator') {
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
feed: this.uri,
})
this.hydrateFeedGenerator(res.data.view)
} else if (this.type === 'list') {
const res = await this.rootStore.agent.app.bsky.graph.getList({
list: this.uri,
limit: 1,
})
this.hydrateList(res.data.list)
}
} catch (e) {
runInAction(() => {
this.error = cleanError(e)
})
}
})
hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) {
this.uri = view.uri
this.cid = view.cid
this.avatar = view.avatar
this.displayName = view.displayName
? sanitizeDisplayName(view.displayName)
: `Feed by ${sanitizeHandle(view.creator.handle, '@')}`
this.descriptionRT = new RichText({
text: view.description || '',
facets: (view.descriptionFacets || [])?.slice(),
})
this.creatorDid = view.creator.did
this.creatorHandle = view.creator.handle
this.likeCount = view.likeCount
this.likeUri = view.viewer?.like
this.hasLoaded = true
}
hydrateList(view: AppBskyGraphDefs.ListView) {
this.uri = view.uri
this.cid = view.cid
this.avatar = view.avatar
this.displayName = view.name
? sanitizeDisplayName(view.name)
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`
this.descriptionRT = new RichText({
text: view.description || '',
facets: (view.descriptionFacets || [])?.slice(),
})
this.creatorDid = view.creator.did
this.creatorHandle = view.creator.handle
this.likeCount = undefined
this.hasLoaded = true
}
async save() {
if (this.type !== 'feed-generator') {
return
}
try {
await this.rootStore.preferences.addSavedFeed(this.uri)
} catch (error) {
logger.error('Failed to save feed', {error})
} finally {
track('CustomFeed:Save')
}
}
async unsave() {
// TODO TEMPORARY — see PRF's comment in content/list.ts togglePin
if (this.type !== 'feed-generator' && this.type !== 'list') {
return
}
try {
await this.rootStore.preferences.removeSavedFeed(this.uri)
} catch (error) {
logger.error('Failed to unsave feed', {error})
} finally {
track('CustomFeed:Unsave')
}
}
async pin() {
try {
await this.rootStore.preferences.addPinnedFeed(this.uri)
} catch (error) {
logger.error('Failed to pin feed', {error})
} finally {
track('CustomFeed:Pin', {
name: this.displayName,
uri: this.uri,
})
}
}
async togglePin() {
if (!this.isPinned) {
track('CustomFeed:Pin', {
name: this.displayName,
uri: this.uri,
})
return this.rootStore.preferences.addPinnedFeed(this.uri)
} else {
track('CustomFeed:Unpin', {
name: this.displayName,
uri: this.uri,
})
if (this.type === 'list') {
// TODO TEMPORARY — see PRF's comment in content/list.ts togglePin
return this.unsave()
} else {
return this.rootStore.preferences.removePinnedFeed(this.uri)
}
}
}
async like() {
if (this.type !== 'feed-generator') {
return
}
try {
this.likeUri = 'pending'
this.likeCount = (this.likeCount || 0) + 1
const res = await this.rootStore.agent.like(this.uri, this.cid)
this.likeUri = res.uri
} catch (e: any) {
this.likeUri = undefined
this.likeCount = (this.likeCount || 1) - 1
logger.error('Failed to like feed', {error: e})
} finally {
track('CustomFeed:Like')
}
}
async unlike() {
if (this.type !== 'feed-generator') {
return
}
if (!this.likeUri) {
return
}
const uri = this.likeUri
try {
this.likeUri = undefined
this.likeCount = (this.likeCount || 1) - 1
await this.rootStore.agent.deleteLike(uri!)
} catch (e: any) {
this.likeUri = uri
this.likeCount = (this.likeCount || 0) + 1
logger.error('Failed to unlike feed', {error: e})
} finally {
track('CustomFeed:Unlike')
}
}
}
-130
View File
@@ -1,130 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {AtUri, AppBskyGraphListitem} from '@atproto/api'
import {runInAction} from 'mobx'
import {RootStoreModel} from '../root-store'
const PAGE_SIZE = 100
interface Membership {
uri: string
value: AppBskyGraphListitem.Record
}
interface ListitemRecord {
uri: string
value: AppBskyGraphListitem.Record
}
interface ListitemListResponse {
cursor?: string
records: ListitemRecord[]
}
export class ListMembershipModel {
// data
memberships: Membership[] = []
constructor(public rootStore: RootStoreModel, public subject: string) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
// public api
// =
async fetch() {
// NOTE
// this approach to determining list membership is too inefficient to work at any scale
// it needs to be replaced with server side list membership queries
// -prf
let cursor
let records: ListitemRecord[] = []
for (let i = 0; i < 100; i++) {
const res: ListitemListResponse =
await this.rootStore.agent.app.bsky.graph.listitem.list({
repo: this.rootStore.me.did,
cursor,
limit: PAGE_SIZE,
})
records = records.concat(
res.records.filter(record => record.value.subject === this.subject),
)
cursor = res.cursor
if (!cursor) {
break
}
}
runInAction(() => {
this.memberships = records
})
}
getMembership(listUri: string) {
return this.memberships.find(m => m.value.list === listUri)
}
isMember(listUri: string) {
return !!this.getMembership(listUri)
}
async add(listUri: string) {
if (this.isMember(listUri)) {
return
}
const res = await this.rootStore.agent.app.bsky.graph.listitem.create(
{
repo: this.rootStore.me.did,
},
{
subject: this.subject,
list: listUri,
createdAt: new Date().toISOString(),
},
)
const {rkey} = new AtUri(res.uri)
const record = await this.rootStore.agent.app.bsky.graph.listitem.get({
repo: this.rootStore.me.did,
rkey,
})
runInAction(() => {
this.memberships = this.memberships.concat([record])
})
}
async remove(listUri: string) {
const membership = this.getMembership(listUri)
if (!membership) {
return
}
const {rkey} = new AtUri(membership.uri)
await this.rootStore.agent.app.bsky.graph.listitem.delete({
repo: this.rootStore.me.did,
rkey,
})
runInAction(() => {
this.memberships = this.memberships.filter(m => m.value.list !== listUri)
})
}
async updateTo(
uris: string[],
): Promise<{added: string[]; removed: string[]}> {
const added = []
const removed = []
for (const uri of uris) {
await this.add(uri)
added.push(uri)
}
for (const membership of this.memberships) {
if (!uris.includes(membership.value.list)) {
await this.remove(membership.value.list)
removed.push(membership.value.list)
}
}
return {added, removed}
}
}
-508
View File
@@ -1,508 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AtUri,
AppBskyActorDefs,
AppBskyGraphGetList as GetList,
AppBskyGraphDefs as GraphDefs,
AppBskyGraphList,
AppBskyGraphListitem,
RichText,
} from '@atproto/api'
import {Image as RNImage} from 'react-native-image-crop-picker'
import chunk from 'lodash.chunk'
import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {track} from 'lib/analytics/analytics'
import {until} from 'lib/async/until'
import {logger} from '#/logger'
const PAGE_SIZE = 30
interface ListitemRecord {
uri: string
value: AppBskyGraphListitem.Record
}
interface ListitemListResponse {
cursor?: string
records: ListitemRecord[]
}
export class ListModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
loadMoreError = ''
hasMore = true
loadMoreCursor?: string
// data
data: GraphDefs.ListView | null = null
items: GraphDefs.ListItemView[] = []
descriptionRT: RichText | null = null
static async createList(
rootStore: RootStoreModel,
{
purpose,
name,
description,
avatar,
}: {
purpose: string
name: string
description: string
avatar: RNImage | null | undefined
},
) {
if (
purpose !== 'app.bsky.graph.defs#curatelist' &&
purpose !== 'app.bsky.graph.defs#modlist'
) {
throw new Error('Invalid list purpose: must be curatelist or modlist')
}
const record: AppBskyGraphList.Record = {
purpose,
name,
description,
avatar: undefined,
createdAt: new Date().toISOString(),
}
if (avatar) {
const blobRes = await apilib.uploadBlob(
rootStore,
avatar.path,
avatar.mime,
)
record.avatar = blobRes.data.blob
}
const res = await rootStore.agent.app.bsky.graph.list.create(
{
repo: rootStore.me.did,
},
record,
)
// wait for the appview to update
await until(
5, // 5 tries
1e3, // 1s delay between tries
(v: GetList.Response, _e: any) => {
return typeof v?.data?.list.uri === 'string'
},
() =>
rootStore.agent.app.bsky.graph.getList({
list: res.uri,
limit: 1,
}),
)
return res
}
constructor(public rootStore: RootStoreModel, public uri: string) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.items.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get isCuratelist() {
return this.data?.purpose === 'app.bsky.graph.defs#curatelist'
}
get isModlist() {
return this.data?.purpose === 'app.bsky.graph.defs#modlist'
}
get isOwner() {
return this.data?.creator.did === this.rootStore.me.did
}
get isBlocking() {
return !!this.data?.viewer?.blocked
}
get isMuting() {
return !!this.data?.viewer?.muted
}
get isPinned() {
return this.rootStore.preferences.isPinnedFeed(this.uri)
}
get creatorDid() {
return this.data?.creator.did
}
getMembership(did: string) {
return this.items.find(item => item.subject.did === did)
}
isMember(did: string) {
return !!this.getMembership(did)
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
await this._resolveUri()
const res = await this.rootStore.agent.app.bsky.graph.getList({
list: this.uri,
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(replace ? e : undefined, !replace ? e : undefined)
}
})
async loadAll() {
for (let i = 0; i < 1000; i++) {
if (!this.hasMore) {
break
}
await this.loadMore()
}
}
async updateMetadata({
name,
description,
avatar,
}: {
name: string
description: string
avatar: RNImage | null | undefined
}) {
if (!this.data) {
return
}
if (!this.isOwner) {
throw new Error('Cannot edit this list')
}
await this._resolveUri()
// get the current record
const {rkey} = new AtUri(this.uri)
const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({
repo: this.rootStore.me.did,
rkey,
})
// update the fields
record.name = name
record.description = description
if (avatar) {
const blobRes = await apilib.uploadBlob(
this.rootStore,
avatar.path,
avatar.mime,
)
record.avatar = blobRes.data.blob
} else if (avatar === null) {
record.avatar = undefined
}
return await this.rootStore.agent.com.atproto.repo.putRecord({
repo: this.rootStore.me.did,
collection: 'app.bsky.graph.list',
rkey,
record,
})
}
async delete() {
if (!this.data) {
return
}
await this._resolveUri()
// fetch all the listitem records that belong to this list
let cursor
let records: ListitemRecord[] = []
for (let i = 0; i < 100; i++) {
const res: ListitemListResponse =
await this.rootStore.agent.app.bsky.graph.listitem.list({
repo: this.rootStore.me.did,
cursor,
limit: PAGE_SIZE,
})
records = records.concat(
res.records.filter(record => record.value.list === this.uri),
)
cursor = res.cursor
if (!cursor) {
break
}
}
// batch delete the list and listitem records
const createDel = (uri: string) => {
const urip = new AtUri(uri)
return {
$type: 'com.atproto.repo.applyWrites#delete',
collection: urip.collection,
rkey: urip.rkey,
}
}
const writes = records
.map(record => createDel(record.uri))
.concat([createDel(this.uri)])
// apply in chunks
for (const writesChunk of chunk(writes, 10)) {
await this.rootStore.agent.com.atproto.repo.applyWrites({
repo: this.rootStore.me.did,
writes: writesChunk,
})
}
/* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
this.rootStore.emitListDeleted(this.uri)
}
async addMember(profile: AppBskyActorDefs.ProfileViewBasic) {
if (this.isMember(profile.did)) {
return
}
await this.rootStore.agent.app.bsky.graph.listitem.create(
{
repo: this.rootStore.me.did,
},
{
subject: profile.did,
list: this.uri,
createdAt: new Date().toISOString(),
},
)
runInAction(() => {
this.items = this.items.concat([
{_reactKey: profile.did, subject: profile},
])
})
}
/**
* Just adds to local cache; used to reflect changes affected elsewhere
*/
cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) {
if (!this.isMember(profile.did)) {
this.items = this.items.concat([
{_reactKey: profile.did, subject: profile},
])
}
}
/**
* Just removes from local cache; used to reflect changes affected elsewhere
*/
cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) {
if (this.isMember(profile.did)) {
this.items = this.items.filter(item => item.subject.did !== profile.did)
}
}
async pin() {
try {
await this.rootStore.preferences.addPinnedFeed(this.uri)
} catch (error) {
logger.error('Failed to pin feed', {error})
} finally {
track('CustomFeed:Pin', {
name: this.data?.name || '',
uri: this.uri,
})
}
}
async togglePin() {
if (!this.isPinned) {
track('CustomFeed:Pin', {
name: this.data?.name || '',
uri: this.uri,
})
return this.rootStore.preferences.addPinnedFeed(this.uri)
} else {
track('CustomFeed:Unpin', {
name: this.data?.name || '',
uri: this.uri,
})
// TODO TEMPORARY
// lists are temporarily piggybacking on the saved/pinned feeds preferences
// we'll eventually replace saved feeds with the bookmarks API
// until then, we need to unsave lists instead of just unpin them
// -prf
// return this.rootStore.preferences.removePinnedFeed(this.uri)
return this.rootStore.preferences.removeSavedFeed(this.uri)
}
}
async mute() {
if (!this.data) {
return
}
await this._resolveUri()
await this.rootStore.agent.muteModList(this.data.uri)
track('Lists:Mute')
runInAction(() => {
if (this.data) {
const d = this.data
this.data = {...d, viewer: {...(d.viewer || {}), muted: true}}
}
})
}
async unmute() {
if (!this.data) {
return
}
await this._resolveUri()
await this.rootStore.agent.unmuteModList(this.data.uri)
track('Lists:Unmute')
runInAction(() => {
if (this.data) {
const d = this.data
this.data = {...d, viewer: {...(d.viewer || {}), muted: false}}
}
})
}
async block() {
if (!this.data) {
return
}
await this._resolveUri()
const res = await this.rootStore.agent.blockModList(this.data.uri)
track('Lists:Block')
runInAction(() => {
if (this.data) {
const d = this.data
this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}}
}
})
}
async unblock() {
if (!this.data || !this.data.viewer?.blocked) {
return
}
await this._resolveUri()
await this.rootStore.agent.unblockModList(this.data.uri)
track('Lists:Unblock')
runInAction(() => {
if (this.data) {
const d = this.data
this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}}
}
})
}
/**
* Attempt to load more again after a failure
*/
async retryLoadMore() {
this.loadMoreError = ''
this.hasMore = true
return this.loadMore()
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any, loadMoreErr?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
this.loadMoreError = cleanError(loadMoreErr)
if (err) {
logger.error('Failed to fetch user items', {error: err})
}
if (loadMoreErr) {
logger.error('Failed to fetch user items', {
error: loadMoreErr,
})
}
}
// helper functions
// =
async _resolveUri() {
const urip = new AtUri(this.uri)
if (!urip.host.startsWith('did:')) {
try {
urip.host = await apilib.resolveName(this.rootStore, urip.host)
} catch (e: any) {
runInAction(() => {
this.error = e.toString()
})
}
}
runInAction(() => {
this.uri = urip.toString()
})
}
_replaceAll(res: GetList.Response) {
this.items = []
this._appendAll(res)
}
_appendAll(res: GetList.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.data = res.data.list
this.items = this.items.concat(
res.data.items.map(item => ({...item, _reactKey: item.subject.did})),
)
if (this.data.description) {
this.descriptionRT = new RichText({
text: this.data.description,
facets: (this.data.descriptionFacets || [])?.slice(),
})
} else {
this.descriptionRT = null
}
}
}
@@ -1,131 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyFeedPost as FeedPost,
AppBskyFeedDefs,
RichText,
PostModeration,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {PostsFeedItemModel} from '../feeds/post'
type PostView = AppBskyFeedDefs.PostView
// NOTE: this model uses the same data as PostsFeedItemModel, but is used for
// rendering a single post in a thread view, and has additional state
// for rendering the thread view, but calls the same data methods
// as PostsFeedItemModel
// TODO: refactor as an extension or subclass of PostsFeedItemModel
export class PostThreadItemModel {
// ui state
_reactKey: string = ''
_depth = 0
_isHighlightedPost = false
_showParentReplyLine = false
_showChildReplyLine = false
_hasMore = false
// data
data: PostsFeedItemModel
post: PostView
postRecord?: FeedPost.Record
richText?: RichText
parent?:
| PostThreadItemModel
| AppBskyFeedDefs.NotFoundPost
| AppBskyFeedDefs.BlockedPost
replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[]
constructor(
public rootStore: RootStoreModel,
v: AppBskyFeedDefs.ThreadViewPost,
) {
this._reactKey = `thread-${v.post.uri}`
this.data = new PostsFeedItemModel(rootStore, this._reactKey, v)
this.post = this.data.post
this.postRecord = this.data.postRecord
this.richText = this.data.richText
// replies and parent are handled via assignTreeModels
makeAutoObservable(this, {rootStore: false})
}
get uri() {
return this.post.uri
}
get parentUri() {
return this.postRecord?.reply?.parent.uri
}
get rootUri(): string {
if (this.postRecord?.reply?.root.uri) {
return this.postRecord.reply.root.uri
}
return this.post.uri
}
get 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 delete() {
this.data.delete()
}
}
-342
View File
@@ -1,342 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AppBskyFeedGetPostThread as GetPostThread,
AppBskyFeedDefs,
AppBskyFeedPost,
PostModeration,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {ThreadViewPreference} from '../ui/preferences'
import {PostThreadItemModel} from './post-thread-item'
import {logger} from '#/logger'
export class PostThreadModel {
// state
isLoading = false
isLoadingFromCache = false
isFromCache = false
isRefreshing = false
hasLoaded = false
error = ''
notFound = false
resolvedUri = ''
params: GetPostThread.QueryParams
// data
thread?: PostThreadItemModel | null = null
isBlocked = false
constructor(
public rootStore: RootStoreModel,
params: GetPostThread.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
static fromPostView(
rootStore: RootStoreModel,
postView: AppBskyFeedDefs.PostView,
) {
const model = new PostThreadModel(rootStore, {uri: postView.uri})
model.resolvedUri = postView.uri
model.hasLoaded = true
model.thread = new PostThreadItemModel(rootStore, {
post: postView,
})
return model
}
get hasContent() {
return !!this.thread
}
get hasError() {
return this.error !== ''
}
get rootUri(): string {
if (this.thread) {
if (this.thread.postRecord?.reply?.root.uri) {
return this.thread.postRecord.reply.root.uri
}
}
return this.resolvedUri
}
get 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()
}
// 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
@@ -1,306 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AtUri,
ComAtprotoLabelDefs,
AppBskyGraphDefs,
AppBskyActorGetProfile as GetProfile,
AppBskyActorProfile,
RichText,
moderateProfile,
ProfileModeration,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {FollowState} from '../cache/my-follows'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
export class ProfileViewerModel {
muted?: boolean
mutedByList?: AppBskyGraphDefs.ListViewBasic
following?: string
followedBy?: string
blockedBy?: boolean
blocking?: string
blockingByList?: AppBskyGraphDefs.ListViewBasic;
[key: string]: unknown
constructor() {
makeAutoObservable(this)
}
}
export class ProfileModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: GetProfile.QueryParams
// data
did: string = ''
handle: string = ''
creator: string = ''
displayName?: string = ''
description?: string = ''
avatar?: string = ''
banner?: string = ''
followersCount: number = 0
followsCount: number = 0
postsCount: number = 0
labels?: ComAtprotoLabelDefs.Label[] = undefined
viewer = new ProfileViewerModel();
[key: string]: unknown
// added data
descriptionRichText?: RichText = new RichText({text: ''})
constructor(
public rootStore: RootStoreModel,
params: GetProfile.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.did !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get moderation(): ProfileModeration {
return moderateProfile(this, this.rootStore.preferences.moderationOpts)
}
// public api
// =
async setup() {
const precache = await this.rootStore.profiles.cache.get(this.params.actor)
if (precache) {
await this._loadWithCache(precache)
} else {
await this._load()
}
}
async refresh() {
await this._load(true)
}
async toggleFollowing() {
if (!this.rootStore.me.did) {
throw new Error('Not logged in')
}
const follows = this.rootStore.me.follows
const followUri =
(await follows.fetchFollowState(this.did)) === FollowState.Following
? follows.getFollowUri(this.did)
: undefined
// guard against this view getting out of sync with the follows cache
if (followUri !== this.viewer.following) {
this.viewer.following = followUri
return
}
if (followUri) {
// unfollow
await this.rootStore.agent.deleteFollow(followUri)
runInAction(() => {
this.followersCount--
this.viewer.following = undefined
this.rootStore.me.follows.removeFollow(this.did)
})
track('Profile:Unfollow', {
username: this.handle,
})
} else {
// follow
const res = await this.rootStore.agent.follow(this.did)
runInAction(() => {
this.followersCount++
this.viewer.following = res.uri
this.rootStore.me.follows.hydrate(this.did, this)
})
track('Profile:Follow', {
username: this.handle,
})
}
}
async updateProfile(
updates: AppBskyActorProfile.Record,
newUserAvatar: RNImage | undefined | null,
newUserBanner: RNImage | undefined | null,
) {
await this.rootStore.agent.upsertProfile(async existing => {
existing = existing || {}
existing.displayName = updates.displayName
existing.description = updates.description
if (newUserAvatar) {
const res = await apilib.uploadBlob(
this.rootStore,
newUserAvatar.path,
newUserAvatar.mime,
)
existing.avatar = res.data.blob
} else if (newUserAvatar === null) {
existing.avatar = undefined
}
if (newUserBanner) {
const res = await apilib.uploadBlob(
this.rootStore,
newUserBanner.path,
newUserBanner.mime,
)
existing.banner = res.data.blob
} else if (newUserBanner === null) {
existing.banner = undefined
}
return existing
})
await this.rootStore.me.load()
await this.refresh()
}
async muteAccount() {
await this.rootStore.agent.mute(this.did)
this.viewer.muted = true
await this.refresh()
}
async unmuteAccount() {
await this.rootStore.agent.unmute(this.did)
this.viewer.muted = false
await this.refresh()
}
async blockAccount() {
const res = await this.rootStore.agent.app.bsky.graph.block.create(
{
repo: this.rootStore.me.did,
},
{
subject: this.did,
createdAt: new Date().toISOString(),
},
)
this.viewer.blocking = res.uri
await this.refresh()
}
async unblockAccount() {
if (!this.viewer.blocking) {
return
}
const {rkey} = new AtUri(this.viewer.blocking)
await this.rootStore.agent.app.bsky.graph.block.delete({
repo: this.rootStore.me.did,
rkey,
})
this.viewer.blocking = undefined
await this.refresh()
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch profile', {error: err})
}
}
// loader functions
// =
async _load(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const res = await this.rootStore.agent.getProfile(this.params)
this.rootStore.profiles.overwrite(this.params.actor, res)
if (res.data.handle) {
this.rootStore.handleResolutions.cache.set(
res.data.handle,
res.data.did,
)
}
this._replaceAll(res)
await this._createRichText()
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
async _loadWithCache(precache: GetProfile.Response) {
// use cached value
this._replaceAll(precache)
await this._createRichText()
this._xIdle()
// fetch latest
try {
const res = await this.rootStore.agent.getProfile(this.params)
this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
this._replaceAll(res)
await this._createRichText()
} catch (e: any) {
this._xIdle(e)
}
}
_replaceAll(res: GetProfile.Response) {
this.did = res.data.did
this.handle = res.data.handle
this.displayName = res.data.displayName
this.description = res.data.description
this.avatar = res.data.avatar
this.banner = res.data.banner
this.followersCount = res.data.followersCount || 0
this.followsCount = res.data.followsCount || 0
this.postsCount = res.data.postsCount || 0
this.labels = res.data.labels
if (res.data.viewer) {
Object.assign(this.viewer, res.data.viewer)
}
this.rootStore.me.follows.hydrate(this.did, res.data)
}
async _createRichText() {
this.descriptionRichText = new RichText(
{text: this.description || ''},
{cleanNewlines: true},
)
await this.descriptionRichText.detectFacets(this.rootStore.agent)
}
}
-148
View File
@@ -1,148 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import {FeedSourceModel} from '../content/feed-source'
import {logger} from '#/logger'
const DEFAULT_LIMIT = 50
export class FeedsDiscoveryModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
loadMoreCursor: string | undefined = undefined
// data
feeds: FeedSourceModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasMore() {
if (this.loadMoreCursor) {
return true
}
return false
}
get hasContent() {
return this.feeds.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
refresh = bundleAsync(async () => {
this._xLoading()
try {
const res =
await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
limit: DEFAULT_LIMIT,
})
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
loadMore = bundleAsync(async () => {
if (!this.hasMore) {
return
}
this._xLoading()
try {
const res =
await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
limit: DEFAULT_LIMIT,
cursor: this.loadMoreCursor,
})
this._append(res)
} catch (e: any) {
this._xIdle(e)
}
this._xIdle()
})
search = async (query: string) => {
this._xLoading(false)
try {
const results =
await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({
limit: DEFAULT_LIMIT,
query: query,
})
this._replaceAll(results)
} catch (e: any) {
this._xIdle(e)
}
this._xIdle()
}
clear() {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = false
this.error = ''
this.feeds = []
}
// state transitions
// =
_xLoading(isRefreshing = true) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch popular feeds', {error: err})
}
}
// helper functions
// =
_replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
// 1. set feeds data to empty array
this.feeds = []
// 2. call this._append()
this._append(res)
}
_append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
// 1. push data into feeds array
for (const f of res.data.feeds) {
const model = new FeedSourceModel(this.rootStore, f.uri)
model.hydrateFeedGenerator(f)
this.feeds.push(model)
}
// 2. set loadMoreCursor
this.loadMoreCursor = res.data.cursor
}
}
-132
View File
@@ -1,132 +0,0 @@
import {AppBskyActorDefs} from '@atproto/api'
import {makeAutoObservable, runInAction} from 'mobx'
import sampleSize from 'lodash.samplesize'
import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store'
export type RefWithInfoAndFollowers = AppBskyActorDefs.ProfileViewBasic & {
followers: AppBskyActorDefs.ProfileView[]
}
export type ProfileViewFollows = AppBskyActorDefs.ProfileView & {
follows: AppBskyActorDefs.ProfileViewBasic[]
}
export class FoafsModel {
isLoading = false
hasData = false
sources: string[] = []
foafs: Map<string, ProfileViewFollows> = new Map() // FOAF stands for Friend of a Friend
popular: RefWithInfoAndFollowers[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this)
}
get hasContent() {
if (this.popular.length > 0) {
return true
}
for (const foaf of this.foafs.values()) {
if (foaf.follows.length) {
return true
}
}
return false
}
fetch = bundleAsync(async () => {
try {
this.isLoading = true
// fetch some of the user's follows
await this.rootStore.me.follows.syncIfNeeded()
// grab 10 of the users followed by the user
runInAction(() => {
this.sources = sampleSize(
Object.keys(this.rootStore.me.follows.byDid),
10,
)
})
if (this.sources.length === 0) {
return
}
runInAction(() => {
this.foafs.clear()
this.popular.length = 0
})
// fetch their profiles
const profiles = await this.rootStore.agent.getProfiles({
actors: this.sources,
})
// fetch their follows
const results = await Promise.allSettled(
this.sources.map(source =>
this.rootStore.agent.getFollows({actor: source}),
),
)
// store the follows and construct a "most followed" set
const popular: RefWithInfoAndFollowers[] = []
for (let i = 0; i < results.length; i++) {
const res = results[i]
if (res.status === 'fulfilled') {
this.rootStore.me.follows.hydrateMany(res.value.data.follows)
}
const profile = profiles.data.profiles[i]
const source = this.sources[i]
if (res.status === 'fulfilled' && profile) {
// filter out inappropriate suggestions
res.value.data.follows = res.value.data.follows.filter(follow => {
const viewer = follow.viewer
if (viewer) {
if (
viewer.following ||
viewer.muted ||
viewer.mutedByList ||
viewer.blockedBy ||
viewer.blocking
) {
return false
}
}
if (follow.did === this.rootStore.me.did) {
return false
}
return true
})
runInAction(() => {
this.foafs.set(source, {
...profile,
follows: res.value.data.follows,
})
})
for (const follow of res.value.data.follows) {
let item = popular.find(p => p.did === follow.did)
if (!item) {
item = {...follow, followers: []}
popular.push(item)
}
item.followers.push(profile)
}
}
}
popular.sort((a, b) => b.followers.length - a.followers.length)
runInAction(() => {
this.popular = popular.filter(p => p.followers.length > 1).slice(0, 20)
})
this.hasData = true
} catch (e) {
console.error('Failed to fetch FOAFs', e)
} finally {
runInAction(() => {
this.isLoading = false
})
}
})
}
@@ -1,151 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export type SuggestedActor =
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileView
export class SuggestedActorsModel {
// state
pageSize = PAGE_SIZE
isLoading = false
isRefreshing = false
hasLoaded = false
loadMoreCursor: string | undefined = undefined
error = ''
hasMore = false
lastInsertedAtIndex = -1
// data
suggestions: SuggestedActor[] = []
constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) {
if (opts?.pageSize) {
this.pageSize = opts.pageSize
}
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.suggestions.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (replace) {
this.hasMore = true
this.loadMoreCursor = undefined
}
if (!this.hasMore) {
return
}
this._xLoading(replace)
try {
const res = await this.rootStore.agent.app.bsky.actor.getSuggestions({
limit: 25,
cursor: this.loadMoreCursor,
})
let {actors, cursor} = res.data
actors = actors.filter(
actor =>
!moderateProfile(actor, this.rootStore.preferences.moderationOpts)
.account.filter,
)
this.rootStore.me.follows.hydrateMany(actors)
runInAction(() => {
if (replace) {
this.suggestions = []
}
this.loadMoreCursor = cursor
this.hasMore = !!cursor
this.suggestions = this.suggestions.concat(
actors.filter(actor => {
const viewer = actor.viewer
if (viewer) {
if (
viewer.following ||
viewer.muted ||
viewer.mutedByList ||
viewer.blockedBy ||
viewer.blocking
) {
return false
}
}
if (actor.did === this.rootStore.me.did) {
return false
}
return true
}),
)
})
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
async insertSuggestionsByActor(actor: string, indexToInsertAt: number) {
// fetch suggestions
const res =
await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({
actor: actor,
})
const {suggestions: moreSuggestions} = res.data
this.rootStore.me.follows.hydrateMany(moreSuggestions)
// dedupe
const toInsert = moreSuggestions.filter(
s => !this.suggestions.find(s2 => s2.did === s.did),
)
// insert
this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert)
// update index
this.lastInsertedAtIndex = indexToInsertAt
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch suggested actors', {error: err})
}
}
}
@@ -1,143 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {AppBskyActorDefs} from '@atproto/api'
import AwaitLock from 'await-lock'
import {RootStoreModel} from '../root-store'
import {isInvalidHandle} from 'lib/strings/handles'
type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic
export class UserAutocompleteModel {
// state
isLoading = false
isActive = false
prefix = ''
lock = new AwaitLock()
// data
knownHandles: Set<string> = new Set()
_suggestions: ProfileViewBasic[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
knownHandles: false,
},
{autoBind: true},
)
}
get follows(): ProfileViewBasic[] {
return Object.values(this.rootStore.me.follows.byDid).map(item => ({
did: item.did,
handle: item.handle,
displayName: item.displayName,
avatar: item.avatar,
}))
}
get suggestions(): ProfileViewBasic[] {
if (!this.isActive) {
return []
}
return this._suggestions
}
// public api
// =
async setup() {
this.isLoading = true
await this.rootStore.me.follows.syncIfNeeded()
runInAction(() => {
for (const did in this.rootStore.me.follows.byDid) {
const info = this.rootStore.me.follows.byDid[did]
if (!isInvalidHandle(info.handle)) {
this.knownHandles.add(info.handle)
}
}
this.isLoading = false
})
}
setActive(v: boolean) {
this.isActive = v
}
async setPrefix(prefix: string) {
const origPrefix = prefix.trim().toLocaleLowerCase()
this.prefix = origPrefix
await this.lock.acquireAsync()
try {
if (this.prefix) {
if (this.prefix !== origPrefix) {
return // another prefix was set before we got our chance
}
// reset to follow results
this._computeSuggestions([])
// ask backend
const res = await this.rootStore.agent.searchActorsTypeahead({
term: this.prefix,
limit: 8,
})
this._computeSuggestions(res.data.actors)
// update known handles
runInAction(() => {
for (const u of res.data.actors) {
this.knownHandles.add(u.handle)
}
})
} else {
runInAction(() => {
this._computeSuggestions([])
})
}
} finally {
this.lock.release()
}
}
// internal
// =
_computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) {
if (this.prefix) {
const items: ProfileViewBasic[] = []
for (const item of this.follows) {
if (prefixMatch(this.prefix, item)) {
items.push(item)
}
if (items.length >= 8) {
break
}
}
for (const item of searchRes) {
if (!items.find(item2 => item2.handle === item.handle)) {
items.push({
did: item.did,
handle: item.handle,
displayName: item.displayName,
avatar: item.avatar,
})
}
}
this._suggestions = items
} else {
this._suggestions = this.follows
}
}
}
function prefixMatch(prefix: string, info: ProfileViewBasic): boolean {
if (info.handle.includes(prefix)) {
return true
}
if (info.displayName?.toLocaleLowerCase().includes(prefix)) {
return true
}
return false
}
-671
View File
@@ -1,671 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AppBskyNotificationListNotifications as ListNotifications,
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedLike,
AppBskyGraphFollow,
ComAtprotoLabelDefs,
moderatePost,
moderateProfile,
} from '@atproto/api'
import AwaitLock from 'await-lock'
import chunk from 'lodash.chunk'
import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store'
import {PostThreadModel} from '../content/post-thread'
import {cleanError} from 'lib/strings/errors'
import {logger} from '#/logger'
import {isThreadMuted} from '#/state/muted-threads'
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
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 && isThreadMuted(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
}
-181
View File
@@ -1,181 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyFeedPost as FeedPost,
AppBskyFeedDefs,
RichText,
moderatePost,
PostModeration,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {updateDataOptimistically} from 'lib/async/revertible'
import {track} from 'lib/analytics/analytics'
import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed'
import {logger} from '#/logger'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
type PostView = AppBskyFeedDefs.PostView
export class PostsFeedItemModel {
// ui state
_reactKey: string = ''
// data
post: PostView
postRecord?: FeedPost.Record
reply?: FeedViewPost['reply']
reason?: FeedViewPost['reason']
richText?: RichText
constructor(
public rootStore: RootStoreModel,
_reactKey: string,
v: FeedViewPost,
) {
this._reactKey = _reactKey
this.post = v.post
if (FeedPost.isRecord(this.post.record)) {
const valid = FeedPost.validateRecord(this.post.record)
if (valid.success) {
hackAddDeletedEmbed(this.post)
this.postRecord = this.post.record
this.richText = new RichText(this.postRecord, {cleanNewlines: true})
} else {
this.postRecord = undefined
this.richText = undefined
logger.warn('Received an invalid app.bsky.feed.post record', {
error: valid.error,
})
}
} else {
this.postRecord = undefined
this.richText = undefined
logger.warn(
'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
{record: this.post.record},
)
}
this.reply = v.reply
this.reason = v.reason
makeAutoObservable(this, {rootStore: false})
}
get uri() {
return this.post.uri
}
get parentUri() {
return this.postRecord?.reply?.parent.uri
}
get rootUri(): string {
if (typeof this.postRecord?.reply?.root.uri === 'string') {
return this.postRecord?.reply?.root.uri
}
return this.post.uri
}
get 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 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
@@ -1,91 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store'
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
import {PostsFeedItemModel} from './post'
import {FeedSourceInfo} from 'lib/api/feed/types'
export class PostsFeedSliceModel {
// ui state
_reactKey: string = ''
// data
items: PostsFeedItemModel[] = []
source: FeedSourceInfo | undefined
constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) {
this._reactKey = slice._reactKey
this.source = slice.source
for (let i = 0; i < slice.items.length; i++) {
this.items.push(
new PostsFeedItemModel(
rootStore,
`${this._reactKey} - ${i}`,
slice.items[i],
),
)
}
makeAutoObservable(this, {rootStore: false})
}
get uri() {
if (this.isReply) {
return this.items[1].post.uri
}
return this.items[0].post.uri
}
get isThread() {
return (
this.items.length > 1 &&
this.items.every(
item => item.post.author.did === this.items[0].post.author.did,
)
)
}
get isReply() {
return this.items.length > 1 && !this.isThread
}
get rootItem() {
if (this.isReply) {
return this.items[1]
}
return this.items[0]
}
get moderation() {
// prefer the most stringent item
const topItem = this.items.find(item => item.moderation.content.filter)
if (topItem) {
return topItem.moderation
}
// otherwise just use the first one
return this.items[0].moderation
}
shouldFilter(ignoreFilterForDid: string | undefined): boolean {
const mods = this.items
.filter(item => item.post.author.did !== ignoreFilterForDid)
.map(item => item.moderation)
return !!mods.find(mod => mod.content.filter)
}
containsUri(uri: string) {
return !!this.items.find(item => item.post.uri === uri)
}
isThreadParentAt(i: number) {
if (this.items.length === 1) {
return false
}
return i < this.items.length - 1
}
isThreadChildAt(i: number) {
if (this.items.length === 1) {
return false
}
return i > 0
}
}
-429
View File
@@ -1,429 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AppBskyFeedGetTimeline as GetTimeline,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
AppBskyFeedGetFeed as GetCustomFeed,
AppBskyFeedGetActorLikes as GetActorLikes,
AppBskyFeedGetListFeed as GetListFeed,
} from '@atproto/api'
import AwaitLock from 'await-lock'
import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {FeedTuner} from 'lib/api/feed-manip'
import {PostsFeedSliceModel} from './posts-slice'
import {track} from 'lib/analytics/analytics'
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types'
import {FollowingFeedAPI} from 'lib/api/feed/following'
import {AuthorFeedAPI} from 'lib/api/feed/author'
import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
import {logger} from '#/logger'
const PAGE_SIZE = 30
type FeedType = 'home' | 'following' | 'author' | 'custom' | 'likes' | 'list'
export enum KnownError {
FeedgenDoesNotExist,
FeedgenMisconfigured,
FeedgenBadResponse,
FeedgenOffline,
FeedgenUnknown,
Unknown,
}
type Options = {
/**
* Formats the feed in a flat array with no threading of replies, just
* top-level posts.
*/
isSimpleFeed?: boolean
}
type QueryParams =
| GetTimeline.QueryParams
| GetAuthorFeed.QueryParams
| GetActorLikes.QueryParams
| GetCustomFeed.QueryParams
| GetListFeed.QueryParams
export class PostsFeedModel {
// state
isLoading = false
isRefreshing = false
hasNewLatest = false
hasLoaded = false
isBlocking = false
isBlockedBy = false
error = ''
knownError: KnownError | undefined
loadMoreError = ''
params: QueryParams
hasMore = true
pollCursor: string | undefined
api: FeedAPI
tuner = new FeedTuner()
pageSize = PAGE_SIZE
options: Options = {}
// used to linearize async modifications to state
lock = new AwaitLock()
// used to track if a feed is coming up empty
emptyFetches = 0
// data
slices: PostsFeedSliceModel[] = []
constructor(
public rootStore: RootStoreModel,
public feedType: FeedType,
params: QueryParams,
options?: Options,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
this.options = options || {}
if (feedType === 'home') {
this.api = new MergeFeedAPI(rootStore)
} else if (feedType === 'following') {
this.api = new FollowingFeedAPI(rootStore)
} else if (feedType === 'author') {
this.api = new AuthorFeedAPI(
rootStore,
params as GetAuthorFeed.QueryParams,
)
} else if (feedType === 'likes') {
this.api = new LikesFeedAPI(
rootStore,
params as GetActorLikes.QueryParams,
)
} else if (feedType === 'custom') {
this.api = new CustomFeedAPI(
rootStore,
params as GetCustomFeed.QueryParams,
)
} else if (feedType === 'list') {
this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams)
} else {
this.api = new FollowingFeedAPI(rootStore)
}
}
get reactKey() {
if (this.feedType === 'author') {
return (this.params as GetAuthorFeed.QueryParams).actor
}
if (this.feedType === 'custom') {
return (this.params as GetCustomFeed.QueryParams).feed
}
if (this.feedType === 'list') {
return (this.params as GetListFeed.QueryParams).list
}
return this.feedType
}
get hasContent() {
return this.slices.length !== 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get isLoadingMore() {
return this.isLoading && !this.isRefreshing && this.hasContent
}
setHasNewLatest(v: boolean) {
this.hasNewLatest = v
}
// public api
// =
/**
* Nuke all data
*/
clear() {
logger.debug('FeedModel:clear')
this.isLoading = false
this.isRefreshing = false
this.hasNewLatest = false
this.hasLoaded = false
this.error = ''
this.hasMore = true
this.pollCursor = undefined
this.slices = []
this.tuner.reset()
}
/**
* Load for first render
*/
setup = bundleAsync(async (isRefreshing: boolean = false) => {
logger.debug('FeedModel:setup', {isRefreshing})
if (isRefreshing) {
this.isRefreshing = true // set optimistically for UI
}
await this.lock.acquireAsync()
try {
this.setHasNewLatest(false)
this.api.reset()
this.tuner.reset()
this._xLoading(isRefreshing)
try {
const res = await this.api.fetchNext({limit: this.pageSize})
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
} finally {
this.lock.release()
}
})
/**
* Register any event listeners. Returns a cleanup function.
*/
registerListeners() {
const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
return () => sub.remove()
}
/**
* Reset and load
*/
async refresh() {
await this.setup(true)
}
/**
* Load more posts to the end of the feed
*/
loadMore = bundleAsync(async () => {
await this.lock.acquireAsync()
try {
if (!this.hasMore || this.hasError) {
return
}
this._xLoading()
try {
const res = await this.api.fetchNext({
limit: this.pageSize,
})
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(undefined, e)
runInAction(() => {
this.hasMore = false
})
}
} finally {
this.lock.release()
if (this.feedType === 'custom') {
track('CustomFeed:LoadMore')
}
}
})
/**
* Attempt to load more again after a failure
*/
async retryLoadMore() {
this.loadMoreError = ''
this.hasMore = true
return this.loadMore()
}
/**
* Check if new posts are available
*/
async checkForLatest() {
if (!this.hasLoaded || this.hasNewLatest || this.isLoading) {
return
}
const post = await this.api.peekLatest()
if (post) {
const slices = this.tuner.tune(
[post],
this.rootStore.preferences.getFeedTuners(this.feedType),
{
dryRun: true,
maintainOrder: true,
},
)
if (slices[0]) {
const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0])
if (sliceModel.moderation.content.filter) {
return
}
this.setHasNewLatest(sliceModel.uri !== this.pollCursor)
}
}
}
/**
* Updates the UI after the user has created a post
*/
onPostCreated() {
if (!this.slices.length) {
return this.refresh()
} else {
this.setHasNewLatest(true)
}
}
/**
* Removes posts from the feed upon deletion.
*/
onPostDeleted(uri: string) {
let i
do {
i = this.slices.findIndex(slice => slice.containsUri(uri))
if (i !== -1) {
this.slices.splice(i, 1)
}
} while (i !== -1)
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
this.knownError = undefined
}
_xIdle(error?: any, loadMoreError?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError
this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
this.error = cleanError(error)
this.knownError = detectKnownError(this.feedType, error)
this.loadMoreError = cleanError(loadMoreError)
if (error) {
logger.error('Posts feed request failed', {error})
}
if (loadMoreError) {
logger.error('Posts feed load-more request failed', {
error: loadMoreError,
})
}
}
// helper functions
// =
async _replaceAll(res: FeedAPIResponse) {
this.pollCursor = res.feed[0]?.post.uri
return this._appendAll(res, true)
}
async _appendAll(res: FeedAPIResponse, replace = false) {
this.hasMore = !!res.cursor && res.feed.length > 0
if (replace) {
this.emptyFetches = 0
}
this.rootStore.me.follows.hydrateMany(
res.feed.map(item => item.post.author),
)
for (const item of res.feed) {
this.rootStore.posts.fromFeedItem(item)
}
const slices = this.options.isSimpleFeed
? res.feed.map(item => new FeedViewPostsSlice([item]))
: this.tuner.tune(
res.feed,
this.rootStore.preferences.getFeedTuners(this.feedType),
)
const toAppend: PostsFeedSliceModel[] = []
for (const slice of slices) {
const sliceModel = new PostsFeedSliceModel(this.rootStore, slice)
const dupTest = (item: PostsFeedSliceModel) =>
item._reactKey === sliceModel._reactKey
// sanity check
// if a duplicate _reactKey passes through, the UI breaks hard
if (!replace) {
if (this.slices.find(dupTest) || toAppend.find(dupTest)) {
continue
}
}
toAppend.push(sliceModel)
}
runInAction(() => {
if (replace) {
this.slices = toAppend
} else {
this.slices = this.slices.concat(toAppend)
}
if (toAppend.length === 0) {
this.emptyFetches++
if (this.emptyFetches >= 10) {
this.hasMore = false
}
}
})
}
}
function detectKnownError(
feedType: FeedType,
error: any,
): KnownError | undefined {
if (!error) {
return undefined
}
if (typeof error !== 'string') {
error = error.toString()
}
if (feedType !== 'custom') {
return KnownError.Unknown
}
if (error.includes('could not find feed')) {
return KnownError.FeedgenDoesNotExist
}
if (error.includes('feed unavailable')) {
return KnownError.FeedgenOffline
}
if (error.includes('invalid did document')) {
return KnownError.FeedgenMisconfigured
}
if (error.includes('could not resolve did document')) {
return KnownError.FeedgenMisconfigured
}
if (
error.includes('invalid feed generator service details in did document')
) {
return KnownError.FeedgenMisconfigured
}
if (error.includes('feed provided an invalid response')) {
return KnownError.FeedgenBadResponse
}
return KnownError.FeedgenUnknown
}
-123
View File
@@ -1,123 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import {FeedSourceModel} from '../content/feed-source'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export class ActorFeedsModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
hasMore = true
loadMoreCursor?: string
// data
feeds: FeedSourceModel[] = []
constructor(
public rootStore: RootStoreModel,
public params: GetActorFeeds.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.feeds.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
clear() {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = false
this.error = ''
this.hasMore = true
this.loadMoreCursor = undefined
this.feeds = []
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({
actor: this.params.actor,
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch user followers', {error: err})
}
}
// helper functions
// =
_replaceAll(res: GetActorFeeds.Response) {
this.feeds = []
this._appendAll(res)
}
_appendAll(res: GetActorFeeds.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
for (const f of res.data.feeds) {
const model = new FeedSourceModel(this.rootStore, f.uri)
model.hydrateFeedGenerator(f)
this.feeds.push(model)
}
}
}
-107
View File
@@ -1,107 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyGraphGetBlocks as GetBlocks,
AppBskyActorDefs as ActorDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export class BlockedAccountsModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
hasMore = true
loadMoreCursor?: string
// data
blocks: ActorDefs.ProfileView[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.blocks.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const res = await this.rootStore.agent.app.bsky.graph.getBlocks({
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch user followers', {error: err})
}
}
// helper functions
// =
_replaceAll(res: GetBlocks.Response) {
this.blocks = []
this._appendAll(res)
}
_appendAll(res: GetBlocks.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.blocks = this.blocks.concat(res.data.blocks)
}
}
-135
View File
@@ -1,135 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {AtUri} from '@atproto/api'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import * as apilib from 'lib/api/index'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export type LikeItem = GetLikes.Like
export class LikesModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
resolvedUri = ''
params: GetLikes.QueryParams
hasMore = true
loadMoreCursor?: string
// data
uri: string = ''
likes: LikeItem[] = []
constructor(public rootStore: RootStoreModel, params: GetLikes.QueryParams) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.uri !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
if (!this.resolvedUri) {
await this._resolveUri()
}
const params = Object.assign({}, this.params, {
uri: this.resolvedUri,
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
const res = await this.rootStore.agent.getLikes(params)
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch likes', {error: err})
}
}
// helper functions
// =
async _resolveUri() {
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
try {
urip.host = await apilib.resolveName(this.rootStore, urip.host)
} catch (e: any) {
this.error = e.toString()
}
}
runInAction(() => {
this.resolvedUri = urip.toString()
})
}
_replaceAll(res: GetLikes.Response) {
this.likes = []
this._appendAll(res)
}
_appendAll(res: GetLikes.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.rootStore.me.follows.hydrateMany(
res.data.likes.map(like => like.actor),
)
this.likes = this.likes.concat(res.data.likes)
}
}
-244
View File
@@ -1,244 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {accumulate} from 'lib/async/accumulate'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export class ListsListModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
loadMoreError = ''
hasMore = true
loadMoreCursor?: string
// data
lists: GraphDefs.ListView[] = []
constructor(
public rootStore: RootStoreModel,
public source: 'mine' | 'my-curatelists' | 'my-modlists' | string,
) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.lists.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get curatelists() {
return this.lists.filter(
list => list.purpose === 'app.bsky.graph.defs#curatelist',
)
}
get isCuratelistsEmpty() {
return this.hasLoaded && this.curatelists.length === 0
}
get modlists() {
return this.lists.filter(
list => list.purpose === 'app.bsky.graph.defs#modlist',
)
}
get isModlistsEmpty() {
return this.hasLoaded && this.modlists.length === 0
}
/**
* Removes posts from the feed upon deletion.
*/
onListDeleted(uri: string) {
this.lists = this.lists.filter(l => l.uri !== uri)
}
// public api
// =
/**
* Register any event listeners. Returns a cleanup function.
*/
registerListeners() {
const sub = this.rootStore.onListDeleted(this.onListDeleted.bind(this))
return () => sub.remove()
}
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
let cursor: string | undefined
let lists: GraphDefs.ListView[] = []
if (
this.source === 'mine' ||
this.source === 'my-curatelists' ||
this.source === 'my-modlists'
) {
const promises = [
accumulate(cursor =>
this.rootStore.agent.app.bsky.graph
.getLists({
actor: this.rootStore.me.did,
cursor,
limit: 50,
})
.then(res => ({cursor: res.data.cursor, items: res.data.lists})),
),
]
if (this.source === 'my-modlists') {
promises.push(
accumulate(cursor =>
this.rootStore.agent.app.bsky.graph
.getListMutes({
cursor,
limit: 50,
})
.then(res => ({
cursor: res.data.cursor,
items: res.data.lists,
})),
),
)
promises.push(
accumulate(cursor =>
this.rootStore.agent.app.bsky.graph
.getListBlocks({
cursor,
limit: 50,
})
.then(res => ({
cursor: res.data.cursor,
items: res.data.lists,
})),
),
)
}
const resultset = await Promise.all(promises)
for (const res of resultset) {
for (let list of res) {
if (
this.source === 'my-curatelists' &&
list.purpose !== 'app.bsky.graph.defs#curatelist'
) {
continue
}
if (
this.source === 'my-modlists' &&
list.purpose !== 'app.bsky.graph.defs#modlist'
) {
continue
}
if (!lists.find(l => l.uri === list.uri)) {
lists.push(list)
}
}
}
} else {
const res = await this.rootStore.agent.app.bsky.graph.getLists({
actor: this.source,
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
lists = res.data.lists
cursor = res.data.cursor
}
if (replace) {
this._replaceAll({lists, cursor})
} else {
this._appendAll({lists, cursor})
}
this._xIdle()
} catch (e: any) {
this._xIdle(replace ? e : undefined, !replace ? e : undefined)
}
})
/**
* Attempt to load more again after a failure
*/
async retryLoadMore() {
this.loadMoreError = ''
this.hasMore = true
return this.loadMore()
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any, loadMoreErr?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
this.loadMoreError = cleanError(loadMoreErr)
if (err) {
logger.error('Failed to fetch user lists', {error: err})
}
if (loadMoreErr) {
logger.error('Failed to fetch user lists', {
error: loadMoreErr,
})
}
}
// helper functions
// =
_replaceAll({
lists,
cursor,
}: {
lists: GraphDefs.ListView[]
cursor: string | undefined
}) {
this.lists = []
this._appendAll({lists, cursor})
}
_appendAll({
lists,
cursor,
}: {
lists: GraphDefs.ListView[]
cursor: string | undefined
}) {
this.loadMoreCursor = cursor
this.hasMore = !!this.loadMoreCursor
this.lists = this.lists.concat(
lists.map(list => ({...list, _reactKey: list.uri})),
)
}
}
-107
View File
@@ -1,107 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyGraphGetMutes as GetMutes,
AppBskyActorDefs as ActorDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export class MutedAccountsModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
hasMore = true
loadMoreCursor?: string
// data
mutes: ActorDefs.ProfileView[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.mutes.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const res = await this.rootStore.agent.app.bsky.graph.getMutes({
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch user followers', {error: err})
}
}
// helper functions
// =
_replaceAll(res: GetMutes.Response) {
this.mutes = []
this._appendAll(res)
}
_appendAll(res: GetMutes.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.mutes = this.mutes.concat(res.data.mutes)
}
}
-136
View File
@@ -1,136 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {AtUri} from '@atproto/api'
import {
AppBskyFeedGetRepostedBy as GetRepostedBy,
AppBskyActorDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import * as apilib from 'lib/api/index'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export type RepostedByItem = AppBskyActorDefs.ProfileViewBasic
export class RepostedByModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
resolvedUri = ''
params: GetRepostedBy.QueryParams
hasMore = true
loadMoreCursor?: string
// data
uri: string = ''
repostedBy: RepostedByItem[] = []
constructor(
public rootStore: RootStoreModel,
params: GetRepostedBy.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.uri !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
this._xLoading(replace)
try {
if (!this.resolvedUri) {
await this._resolveUri()
}
const params = Object.assign({}, this.params, {
uri: this.resolvedUri,
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
const res = await this.rootStore.agent.getRepostedBy(params)
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch reposted by view', {error: err})
}
}
// helper functions
// =
async _resolveUri() {
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
try {
urip.host = await apilib.resolveName(this.rootStore, urip.host)
} catch (e: any) {
this.error = e.toString()
}
}
runInAction(() => {
this.resolvedUri = urip.toString()
})
}
_replaceAll(res: GetRepostedBy.Response) {
this.repostedBy = []
this._appendAll(res)
}
_appendAll(res: GetRepostedBy.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
this.rootStore.me.follows.hydrateMany(res.data.repostedBy)
}
}
-121
View File
@@ -1,121 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyGraphGetFollowers as GetFollowers,
AppBskyActorDefs as ActorDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export type FollowerItem = ActorDefs.ProfileViewBasic
export class UserFollowersModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: GetFollowers.QueryParams
hasMore = true
loadMoreCursor?: string
// data
subject: ActorDefs.ProfileViewBasic = {
did: '',
handle: '',
}
followers: FollowerItem[] = []
constructor(
public rootStore: RootStoreModel,
params: GetFollowers.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.subject.did !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
const res = await this.rootStore.agent.getFollowers(params)
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch user followers', {error: err})
}
}
// helper functions
// =
_replaceAll(res: GetFollowers.Response) {
this.followers = []
this._appendAll(res)
}
_appendAll(res: GetFollowers.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.followers = this.followers.concat(res.data.followers)
this.rootStore.me.follows.hydrateMany(res.data.followers)
}
}
-121
View File
@@ -1,121 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyGraphGetFollows as GetFollows,
AppBskyActorDefs as ActorDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export type FollowItem = ActorDefs.ProfileViewBasic
export class UserFollowsModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: GetFollows.QueryParams
hasMore = true
loadMoreCursor?: string
// data
subject: ActorDefs.ProfileViewBasic = {
did: '',
handle: '',
}
follows: FollowItem[] = []
constructor(
public rootStore: RootStoreModel,
params: GetFollows.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.subject.did !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
const res = await this.rootStore.agent.getFollows(params)
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch user follows', err)
}
}
// helper functions
// =
_replaceAll(res: GetFollows.Response) {
this.follows = []
this._appendAll(res)
}
_appendAll(res: GetFollows.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.follows = this.follows.concat(res.data.follows)
this.rootStore.me.follows.hydrateMany(res.data.follows)
}
}
-254
View File
@@ -1,254 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
ComAtprotoServerDefs,
ComAtprotoServerListAppPasswords,
} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {PostsFeedModel} from './feeds/posts'
import {NotificationsFeedModel} from './feeds/notifications'
import {MyFeedsUIModel} from './ui/my-feeds'
import {MyFollowsCache} from './cache/my-follows'
import {isObj, hasProp} from 'lib/type-guards'
import {logger} from '#/logger'
const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec
export class MeModel {
did: string = ''
handle: string = ''
displayName: string = ''
description: string = ''
avatar: string = ''
followsCount: number | undefined
followersCount: number | undefined
mainFeed: PostsFeedModel
notifications: NotificationsFeedModel
myFeeds: MyFeedsUIModel
follows: MyFollowsCache
invites: ComAtprotoServerDefs.InviteCode[] = []
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
lastProfileStateUpdate = Date.now()
lastNotifsUpdate = Date.now()
get invitesAvailable() {
return this.invites.filter(isInviteAvailable).length
}
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{rootStore: false, serialize: false, hydrate: false},
{autoBind: true},
)
this.mainFeed = new PostsFeedModel(this.rootStore, 'home', {
algorithm: 'reverse-chronological',
})
this.notifications = new NotificationsFeedModel(this.rootStore)
this.myFeeds = new MyFeedsUIModel(this.rootStore)
this.follows = new MyFollowsCache(this.rootStore)
}
clear() {
this.mainFeed.clear()
this.notifications.clear()
this.myFeeds.clear()
this.follows.clear()
this.rootStore.profiles.cache.clear()
this.rootStore.posts.cache.clear()
this.did = ''
this.handle = ''
this.displayName = ''
this.description = ''
this.avatar = ''
this.invites = []
this.appPasswords = []
}
serialize(): unknown {
return {
did: this.did,
handle: this.handle,
displayName: this.displayName,
description: this.description,
avatar: this.avatar,
}
}
hydrate(v: unknown) {
if (isObj(v)) {
let did, handle, displayName, description, avatar
if (hasProp(v, 'did') && typeof v.did === 'string') {
did = v.did
}
if (hasProp(v, 'handle') && typeof v.handle === 'string') {
handle = v.handle
}
if (hasProp(v, 'displayName') && typeof v.displayName === 'string') {
displayName = v.displayName
}
if (hasProp(v, 'description') && typeof v.description === 'string') {
description = v.description
}
if (hasProp(v, 'avatar') && typeof v.avatar === 'string') {
avatar = v.avatar
}
if (did && handle) {
this.did = did
this.handle = handle
this.displayName = displayName || ''
this.description = description || ''
this.avatar = avatar || ''
}
}
}
async load() {
const sess = this.rootStore.session
logger.debug('MeModel:load', {hasSession: sess.hasSession})
if (sess.hasSession) {
this.did = sess.currentSession?.did || ''
await this.fetchProfile()
this.mainFeed.clear()
/* dont await */ this.mainFeed.setup().catch(e => {
logger.error('Failed to setup main feed model', {error: e})
})
/* dont await */ this.notifications.setup().catch(e => {
logger.error('Failed to setup notifications model', {
error: e,
})
})
/* dont await */ this.notifications.setup().catch(e => {
logger.error('Failed to setup notifications model', {
error: e,
})
})
this.myFeeds.clear()
/* dont await */ this.myFeeds.saved.refresh()
this.rootStore.emitSessionLoaded()
await this.fetchInviteCodes()
await this.fetchAppPasswords()
} else {
this.clear()
}
}
async updateIfNeeded() {
if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) {
logger.debug('Updating me profile information')
this.lastProfileStateUpdate = Date.now()
await this.fetchProfile()
await this.fetchInviteCodes()
await this.fetchAppPasswords()
}
if (Date.now() - this.lastNotifsUpdate > NOTIFS_UPDATE_INTERVAL) {
this.lastNotifsUpdate = Date.now()
await this.notifications.syncQueue()
}
}
async fetchProfile() {
const profile = await this.rootStore.agent.getProfile({
actor: this.did,
})
runInAction(() => {
if (profile?.data) {
this.displayName = profile.data.displayName || ''
this.description = profile.data.description || ''
this.avatar = profile.data.avatar || ''
this.handle = profile.data.handle || ''
this.followsCount = profile.data.followsCount
this.followersCount = profile.data.followersCount
} else {
this.displayName = ''
this.description = ''
this.avatar = ''
this.followsCount = profile.data.followsCount
this.followersCount = undefined
}
})
}
async fetchInviteCodes() {
if (this.rootStore.session) {
try {
const res =
await this.rootStore.agent.com.atproto.server.getAccountInviteCodes(
{},
)
runInAction(() => {
this.invites = res.data.codes
this.invites.sort((a, b) => {
if (!isInviteAvailable(a)) {
return 1
}
if (!isInviteAvailable(b)) {
return -1
}
return 0
})
})
} catch (e) {
logger.error('Failed to fetch user invite codes', {
error: e,
})
}
}
}
async fetchAppPasswords() {
if (this.rootStore.session) {
try {
const res =
await this.rootStore.agent.com.atproto.server.listAppPasswords({})
runInAction(() => {
this.appPasswords = res.data.passwords
})
} catch (e) {
logger.error('Failed to fetch user app passwords', {
error: e,
})
}
}
}
async createAppPassword(name: string) {
if (this.rootStore.session) {
try {
if (this.appPasswords.find(p => p.name === name)) {
// TODO: this should be handled by the backend but it's not
throw new Error('App password with this name already exists')
}
const res =
await this.rootStore.agent.com.atproto.server.createAppPassword({
name,
})
runInAction(() => {
this.appPasswords.push(res.data)
})
return res.data
} catch (e) {
logger.error('Failed to create app password', {error: e})
}
}
}
async deleteAppPassword(name: string) {
if (this.rootStore.session) {
try {
await this.rootStore.agent.com.atproto.server.revokeAppPassword({
name: name,
})
runInAction(() => {
this.appPasswords = this.appPasswords.filter(p => p.name !== name)
})
} catch (e) {
logger.error('Failed to delete app password', {error: e})
}
}
}
}
function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean {
return invite.available - invite.uses.length > 0 && !invite.disabled
}
+3 -6
View File
@@ -1,5 +1,4 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from 'state/index'
import {ImageModel} from './image'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {openPicker} from 'lib/media/picker'
@@ -8,10 +7,8 @@ import {getImageDim} from 'lib/media/manip'
export class GalleryModel {
images: ImageModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
rootStore: false,
})
constructor() {
makeAutoObservable(this)
}
get isEmpty() {
@@ -33,7 +30,7 @@ export class GalleryModel {
// Temporarily enforce uniqueness but can eventually also use index
if (!this.images.some(i => i.path === image_.path)) {
const image = new ImageModel(this.rootStore, image_)
const image = new ImageModel(image_)
// Initial resize
image.manipulate({})
+3 -6
View File
@@ -1,5 +1,4 @@
import {Image as RNImage} from 'react-native-image-crop-picker'
import {RootStoreModel} from 'state/index'
import {makeAutoObservable, runInAction} from 'mobx'
import {POST_IMG_MAX} from 'lib/constants'
import * as ImageManipulator from 'expo-image-manipulator'
@@ -42,10 +41,8 @@ export class ImageModel implements Omit<RNImage, 'size'> {
}
prevAttributes: ImageManipulationAttributes = {}
constructor(public rootStore: RootStoreModel, image: Omit<RNImage, 'size'>) {
makeAutoObservable(this, {
rootStore: false,
})
constructor(image: Omit<RNImage, 'size'>) {
makeAutoObservable(this)
this.path = image.path
this.width = image.width
@@ -178,7 +175,7 @@ export class ImageModel implements Omit<RNImage, 'size'> {
height: this.height,
})
const cropped = await openCropper(this.rootStore, {
const cropped = await openCropper({
mediaType: 'photo',
path: this.path,
freeStyleCropEnabled: true,
-239
View File
@@ -1,239 +0,0 @@
/**
* The root store is the base of all modeled state.
*/
import {makeAutoObservable} from 'mobx'
import {BskyAgent} from '@atproto/api'
import {createContext, useContext} from 'react'
import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
import {z} from 'zod'
import {isObj, hasProp} from 'lib/type-guards'
import {SessionModel} from './session'
import {ShellUiModel} from './ui/shell'
import {HandleResolutionsCache} from './cache/handle-resolutions'
import {ProfilesCache} from './cache/profiles-view'
import {PostsCache} from './cache/posts'
import {LinkMetasCache} from './cache/link-metas'
import {MeModel} from './me'
import {PreferencesModel} from './ui/preferences'
import {resetToTab} from '../../Navigation'
import {ImageSizesCache} from './cache/image-sizes'
import {reset as resetNavigation} from '../../Navigation'
import {logger} from '#/logger'
// TEMPORARY (APP-700)
// remove after backend testing finishes
// -prf
import {applyDebugHeader} from 'lib/api/debug-appview-proxy-header'
export const appInfo = z.object({
build: z.string(),
name: z.string(),
namespace: z.string(),
version: z.string(),
})
export type AppInfo = z.infer<typeof appInfo>
export class RootStoreModel {
agent: BskyAgent
appInfo?: AppInfo
session = new SessionModel(this)
shell = new ShellUiModel(this)
preferences = new PreferencesModel(this)
me = new MeModel(this)
handleResolutions = new HandleResolutionsCache()
profiles = new ProfilesCache(this)
posts = new PostsCache(this)
linkMetas = new LinkMetasCache(this)
imageSizes = new ImageSizesCache()
constructor(agent: BskyAgent) {
this.agent = agent
makeAutoObservable(this, {
agent: false,
serialize: false,
hydrate: false,
})
}
setAppInfo(info: AppInfo) {
this.appInfo = info
}
serialize(): unknown {
return {
appInfo: this.appInfo,
session: this.session.serialize(),
me: this.me.serialize(),
preferences: this.preferences.serialize(),
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'appInfo')) {
const appInfoParsed = appInfo.safeParse(v.appInfo)
if (appInfoParsed.success) {
this.setAppInfo(appInfoParsed.data)
}
}
if (hasProp(v, 'me')) {
this.me.hydrate(v.me)
}
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}
if (hasProp(v, 'preferences')) {
this.preferences.hydrate(v.preferences)
}
}
}
/**
* Called during init to resume any stored session.
*/
async attemptSessionResumption() {
logger.debug('RootStoreModel:attemptSessionResumption')
try {
await this.session.attemptSessionResumption()
logger.debug('Session initialized', {
hasSession: this.session.hasSession,
})
this.updateSessionState()
} catch (e: any) {
logger.warn('Failed to initialize session', {error: e})
}
}
/**
* Called by the session model. Refreshes session-oriented state.
*/
async handleSessionChange(
agent: BskyAgent,
{hadSession}: {hadSession: boolean},
) {
logger.debug('RootStoreModel:handleSessionChange')
this.agent = agent
applyDebugHeader(this.agent)
this.me.clear()
await this.preferences.sync()
await this.me.load()
if (!hadSession) {
await resetNavigation()
}
this.emitSessionReady()
}
/**
* Called by the session model. Handles session drops by informing the user.
*/
async handleSessionDrop() {
logger.debug('RootStoreModel:handleSessionDrop')
resetToTab('HomeTab')
this.me.clear()
this.emitSessionDropped()
}
/**
* Clears all session-oriented state.
*/
clearAllSessionState() {
logger.debug('RootStoreModel:clearAllSessionState')
this.session.clear()
resetToTab('HomeTab')
this.me.clear()
}
/**
* Periodic poll for new session state.
*/
async updateSessionState() {
if (!this.session.hasSession) {
return
}
try {
await this.me.updateIfNeeded()
await this.preferences.sync()
} catch (e: any) {
logger.error('Failed to fetch latest state', {error: e})
}
}
// global event bus
// =
// - some events need to be passed around between views and models
// in order to keep state in sync; these methods are for that
// a post was deleted by the local user
onPostDeleted(handler: (uri: string) => void): EmitterSubscription {
return DeviceEventEmitter.addListener('post-deleted', handler)
}
emitPostDeleted(uri: string) {
DeviceEventEmitter.emit('post-deleted', uri)
}
// a list was deleted by the local user
onListDeleted(handler: (uri: string) => void): EmitterSubscription {
return DeviceEventEmitter.addListener('list-deleted', handler)
}
emitListDeleted(uri: string) {
DeviceEventEmitter.emit('list-deleted', uri)
}
// the session has started and been fully hydrated
onSessionLoaded(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('session-loaded', handler)
}
emitSessionLoaded() {
DeviceEventEmitter.emit('session-loaded')
}
// the session has completed all setup; good for post-initialization behaviors like triggering modals
onSessionReady(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('session-ready', handler)
}
emitSessionReady() {
DeviceEventEmitter.emit('session-ready')
}
// the session was dropped due to bad/expired refresh tokens
onSessionDropped(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('session-dropped', handler)
}
emitSessionDropped() {
DeviceEventEmitter.emit('session-dropped')
}
// the current screen has changed
// TODO is this still needed?
onNavigation(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('navigation', handler)
}
emitNavigation() {
DeviceEventEmitter.emit('navigation')
}
// a "soft reset" typically means scrolling to top and loading latest
// but it can depend on the screen
onScreenSoftReset(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('screen-soft-reset', handler)
}
emitScreenSoftReset() {
DeviceEventEmitter.emit('screen-soft-reset')
}
// the unread notifications count has changed
onUnreadNotifications(handler: (count: number) => void): EmitterSubscription {
return DeviceEventEmitter.addListener('unread-notifications', handler)
}
emitUnreadNotifications(count: number) {
DeviceEventEmitter.emit('unread-notifications', count)
}
}
const throwawayInst = new RootStoreModel(
new BskyAgent({service: 'http://localhost'}),
) // this will be replaced by the loader, we just need to supply a value at init
const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
export const RootStoreProvider = RootStoreContext.Provider
export const useStores = () => useContext(RootStoreContext)
-472
View File
@@ -1,472 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
BskyAgent,
AtpSessionEvent,
AtpSessionData,
ComAtprotoServerDescribeServer as DescribeServer,
} from '@atproto/api'
import normalizeUrl from 'normalize-url'
import {isObj, hasProp} from 'lib/type-guards'
import {networkRetry} from 'lib/async/retry'
import {z} from 'zod'
import {RootStoreModel} from './root-store'
import {IS_PROD} from 'lib/constants'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
export type ServiceDescription = DescribeServer.OutputSchema
export const activeSession = z.object({
service: z.string(),
did: z.string(),
})
export type ActiveSession = z.infer<typeof activeSession>
export const accountData = z.object({
service: z.string(),
refreshJwt: z.string().optional(),
accessJwt: z.string().optional(),
handle: z.string(),
did: z.string(),
email: z.string().optional(),
displayName: z.string().optional(),
aviUrl: z.string().optional(),
emailConfirmed: z.boolean().optional(),
})
export type AccountData = z.infer<typeof accountData>
interface AdditionalAccountData {
displayName?: string
aviUrl?: string
}
export class SessionModel {
// DEBUG
// emergency log facility to help us track down this logout issue
// remove when resolved
// -prf
_log(message: string, details?: Record<string, any>) {
details = details || {}
details.state = {
data: this.data,
accounts: this.accounts.map(
a =>
`${!!a.accessJwt && !!a.refreshJwt ? '✅' : '❌'} ${a.handle} (${
a.service
})`,
),
isResumingSession: this.isResumingSession,
}
logger.debug(message, details, logger.DebugContext.session)
}
/**
* Currently-active session
*/
data: ActiveSession | null = null
/**
* A listing of the currently & previous sessions
*/
accounts: AccountData[] = []
/**
* Flag to indicate if we're doing our initial-load session resumption
*/
isResumingSession = false
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
rootStore: false,
serialize: false,
hydrate: false,
hasSession: false,
})
}
get currentSession() {
if (!this.data) {
return undefined
}
const {did, service} = this.data
return this.accounts.find(
account =>
normalizeUrl(account.service) === normalizeUrl(service) &&
account.did === did &&
!!account.accessJwt &&
!!account.refreshJwt,
)
}
get hasSession() {
return !!this.currentSession && !!this.rootStore.agent.session
}
get hasAccounts() {
return this.accounts.length >= 1
}
get switchableAccounts() {
return this.accounts.filter(acct => acct.did !== this.data?.did)
}
get emailNeedsConfirmation() {
return !this.currentSession?.emailConfirmed
}
get isSandbox() {
if (!this.data) {
return false
}
return !IS_PROD(this.data.service)
}
serialize(): unknown {
return {
data: this.data,
accounts: this.accounts,
}
}
hydrate(v: unknown) {
this.accounts = []
if (isObj(v)) {
if (hasProp(v, 'data') && activeSession.safeParse(v.data)) {
this.data = v.data as ActiveSession
}
if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) {
for (const account of v.accounts) {
if (accountData.safeParse(account)) {
this.accounts.push(account as AccountData)
}
}
}
}
}
clear() {
this.data = null
}
/**
* Attempts to resume the previous session loaded from storage
*/
async attemptSessionResumption() {
const sess = this.currentSession
if (sess) {
this._log('SessionModel:attemptSessionResumption found stored session')
this.isResumingSession = true
try {
return await this.resumeSession(sess)
} finally {
runInAction(() => {
this.isResumingSession = false
})
}
} else {
this._log(
'SessionModel:attemptSessionResumption has no session to resume',
)
}
}
/**
* Sets the active session
*/
async setActiveSession(agent: BskyAgent, did: string) {
this._log('SessionModel:setActiveSession')
const hadSession = !!this.data
this.data = {
service: agent.service.toString(),
did,
}
await this.rootStore.handleSessionChange(agent, {hadSession})
}
/**
* Upserts a session into the accounts
*/
persistSession(
service: string,
did: string,
event: AtpSessionEvent,
session?: AtpSessionData,
addedInfo?: AdditionalAccountData,
) {
this._log('SessionModel:persistSession', {
service,
did,
event,
hasSession: !!session,
})
const existingAccount = this.accounts.find(
account => account.service === service && account.did === did,
)
// fall back to any preexisting access tokens
let refreshJwt = session?.refreshJwt || existingAccount?.refreshJwt
let accessJwt = session?.accessJwt || existingAccount?.accessJwt
if (event === 'expired') {
// only clear the tokens when they're known to have expired
refreshJwt = undefined
accessJwt = undefined
}
const newAccount = {
service,
did,
refreshJwt,
accessJwt,
handle: session?.handle || existingAccount?.handle || '',
email: session?.email || existingAccount?.email || '',
displayName: addedInfo
? addedInfo.displayName
: existingAccount?.displayName || '',
aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
emailConfirmed: session?.emailConfirmed,
}
if (!existingAccount) {
this.accounts.push(newAccount)
} else {
this.accounts = [
newAccount,
...this.accounts.filter(
account => !(account.service === service && account.did === did),
),
]
}
// if the session expired, fire an event to let the user know
if (event === 'expired') {
this.rootStore.handleSessionDrop()
}
}
/**
* Clears any session tokens from the accounts; used on logout.
*/
clearSessionTokens() {
this._log('SessionModel:clearSessionTokens')
this.accounts = this.accounts.map(acct => ({
service: acct.service,
handle: acct.handle,
did: acct.did,
displayName: acct.displayName,
aviUrl: acct.aviUrl,
email: acct.email,
emailConfirmed: acct.emailConfirmed,
}))
}
/**
* Fetches additional information about an account on load.
*/
async loadAccountInfo(agent: BskyAgent, did: string) {
const res = await agent.getProfile({actor: did}).catch(_e => undefined)
if (res) {
return {
displayName: res.data.displayName,
aviUrl: res.data.avatar,
}
}
}
/**
* Helper to fetch the accounts config settings from an account.
*/
async describeService(service: string): Promise<ServiceDescription> {
const agent = new BskyAgent({service})
const res = await agent.com.atproto.server.describeServer({})
return res.data
}
/**
* Attempt to resume a session that we still have access tokens for.
*/
async resumeSession(account: AccountData): Promise<boolean> {
this._log('SessionModel:resumeSession')
if (!(account.accessJwt && account.refreshJwt && account.service)) {
this._log(
'SessionModel:resumeSession aborted due to lack of access tokens',
)
return false
}
const agent = new BskyAgent({
service: account.service,
persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(account.service, account.did, evt, sess)
},
})
try {
await networkRetry(3, () =>
agent.resumeSession({
accessJwt: account.accessJwt || '',
refreshJwt: account.refreshJwt || '',
did: account.did,
handle: account.handle,
email: account.email,
emailConfirmed: account.emailConfirmed,
}),
)
const addedInfo = await this.loadAccountInfo(agent, account.did)
this.persistSession(
account.service,
account.did,
'create',
agent.session,
addedInfo,
)
this._log('SessionModel:resumeSession succeeded')
} catch (e: any) {
this._log('SessionModel:resumeSession failed', {
error: e.toString(),
})
return false
}
await this.setActiveSession(agent, account.did)
return true
}
/**
* Create a new session.
*/
async login({
service,
identifier,
password,
}: {
service: string
identifier: string
password: string
}) {
this._log('SessionModel:login')
const agent = new BskyAgent({service})
await agent.login({identifier, password})
if (!agent.session) {
throw new Error('Failed to establish session')
}
const did = agent.session.did
const addedInfo = await this.loadAccountInfo(agent, did)
this.persistSession(service, did, 'create', agent.session, addedInfo)
agent.setPersistSessionHandler(
(evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(service, did, evt, sess)
},
)
await this.setActiveSession(agent, did)
this._log('SessionModel:login succeeded')
}
async createAccount({
service,
email,
password,
handle,
inviteCode,
}: {
service: string
email: string
password: string
handle: string
inviteCode?: string
}) {
this._log('SessionModel:createAccount')
const agent = new BskyAgent({service})
await agent.createAccount({
handle,
password,
email,
inviteCode,
})
if (!agent.session) {
throw new Error('Failed to establish session')
}
const did = agent.session.did
const addedInfo = await this.loadAccountInfo(agent, did)
this.persistSession(service, did, 'create', agent.session, addedInfo)
agent.setPersistSessionHandler(
(evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(service, did, evt, sess)
},
)
await this.setActiveSession(agent, did)
this._log('SessionModel:createAccount succeeded')
track('Create Account Successfully')
}
/**
* Close all sessions across all accounts.
*/
async logout() {
this._log('SessionModel:logout')
// TODO
// need to evaluate why deleting the session has caused errors at times
// -prf
/*if (this.hasSession) {
this.rootStore.agent.com.atproto.session.delete().catch((e: any) => {
this.rootStore.log.warn(
'(Minor issue) Failed to delete session on the server',
e,
)
})
}*/
this.clearSessionTokens()
this.rootStore.clearAllSessionState()
}
/**
* Removes an account from the list of stored accounts.
*/
removeAccount(handle: string) {
this.accounts = this.accounts.filter(acc => acc.handle !== handle)
}
/**
* Reloads the session from the server. Useful when account details change, like the handle.
*/
async reloadFromServer() {
const sess = this.currentSession
if (!sess) {
return
}
const res = await this.rootStore.agent
.getProfile({actor: sess.did})
.catch(_e => undefined)
if (res?.success) {
const updated = {
...sess,
handle: res.data.handle,
displayName: res.data.displayName,
aviUrl: res.data.avatar,
}
runInAction(() => {
this.accounts = [
updated,
...this.accounts.filter(
account =>
!(
account.service === updated.service &&
account.did === updated.did
),
),
]
})
await this.rootStore.me.load()
}
}
updateLocalAccountData(changes: Partial<AccountData>) {
this.accounts = this.accounts.map(acct =>
acct.did === this.data?.did ? {...acct, ...changes} : acct,
)
}
}
-217
View File
@@ -1,217 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store'
import {ServiceDescription} from '../session'
import {DEFAULT_SERVICE} from 'state/index'
import {ComAtprotoServerCreateAccount} from '@atproto/api'
import * as EmailValidator from 'email-validator'
import {createFullHandle} from 'lib/strings/handles'
import {cleanError} from 'lib/strings/errors'
import {getAge} from 'lib/strings/time'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
export class CreateAccountModel {
step: number = 1
isProcessing = false
isFetchingServiceDescription = false
didServiceDescriptionFetchFail = false
error = ''
serviceUrl = DEFAULT_SERVICE
serviceDescription: ServiceDescription | undefined = undefined
userDomain = ''
inviteCode = ''
email = ''
password = ''
handle = ''
birthDate = DEFAULT_DATE
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {}, {autoBind: true})
}
get isAge13() {
return getAge(this.birthDate) >= 13
}
get isAge18() {
return getAge(this.birthDate) >= 18
}
// form state controls
// =
next() {
this.error = ''
if (this.step === 2) {
if (!this.isAge13) {
this.error =
'Unfortunately, you do not meet the requirements to create an account.'
return
}
}
this.step++
}
back() {
this.error = ''
this.step--
}
setStep(v: number) {
this.step = v
}
async fetchServiceDescription() {
this.setError('')
this.setIsFetchingServiceDescription(true)
this.setDidServiceDescriptionFetchFail(false)
this.setServiceDescription(undefined)
if (!this.serviceUrl) {
return
}
try {
const desc = await this.rootStore.session.describeService(this.serviceUrl)
this.setServiceDescription(desc)
this.setUserDomain(desc.availableUserDomains[0])
} catch (err: any) {
logger.warn(
`Failed to fetch service description for ${this.serviceUrl}`,
{error: err},
)
this.setError(
'Unable to contact your service. Please check your Internet connection.',
)
this.setDidServiceDescriptionFetchFail(true)
} finally {
this.setIsFetchingServiceDescription(false)
}
}
async submit(onboardingDispatch: OnboardingDispatchContext) {
if (!this.email) {
this.setStep(2)
return this.setError('Please enter your email.')
}
if (!EmailValidator.validate(this.email)) {
this.setStep(2)
return this.setError('Your email appears to be invalid.')
}
if (!this.password) {
this.setStep(2)
return this.setError('Please choose your password.')
}
if (!this.handle) {
this.setStep(3)
return this.setError('Please choose your handle.')
}
this.setError('')
this.setIsProcessing(true)
try {
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
await this.rootStore.session.createAccount({
service: this.serviceUrl,
email: this.email,
handle: createFullHandle(this.handle, this.userDomain),
password: this.password,
inviteCode: this.inviteCode.trim(),
})
/* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate)
track('Create Account')
} catch (e: any) {
onboardingDispatch({type: 'skip'}) // undo starting the onboard
let errMsg = e.toString()
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
errMsg =
'Invite code not accepted. Check that you input it correctly and try again.'
}
logger.error('Failed to create account', {error: e})
this.setIsProcessing(false)
this.setError(cleanError(errMsg))
throw e
}
}
// form state accessors
// =
get canBack() {
return this.step > 1
}
get canNext() {
if (this.step === 1) {
return !!this.serviceDescription
} else if (this.step === 2) {
return (
(!this.isInviteCodeRequired || this.inviteCode) &&
!!this.email &&
!!this.password
)
}
return !!this.handle
}
get isServiceDescribed() {
return !!this.serviceDescription
}
get isInviteCodeRequired() {
return this.serviceDescription?.inviteCodeRequired
}
// setters
// =
setIsProcessing(v: boolean) {
this.isProcessing = v
}
setIsFetchingServiceDescription(v: boolean) {
this.isFetchingServiceDescription = v
}
setDidServiceDescriptionFetchFail(v: boolean) {
this.didServiceDescriptionFetchFail = v
}
setError(v: string) {
this.error = v
}
setServiceUrl(v: string) {
this.serviceUrl = v
}
setServiceDescription(v: ServiceDescription | undefined) {
this.serviceDescription = v
}
setUserDomain(v: string) {
this.userDomain = v
}
setInviteCode(v: string) {
this.inviteCode = v
}
setEmail(v: string) {
this.email = v
}
setPassword(v: string) {
this.password = v
}
setHandle(v: string) {
this.handle = v
}
setBirthDate(v: Date) {
this.birthDate = v
}
}
-182
View File
@@ -1,182 +0,0 @@
import {makeAutoObservable, reaction} from 'mobx'
import {SavedFeedsModel} from './saved-feeds'
import {FeedsDiscoveryModel} from '../discovery/feeds'
import {FeedSourceModel} from '../content/feed-source'
import {RootStoreModel} from '../root-store'
export type MyFeedsItem =
| {
_reactKey: string
type: 'spinner'
}
| {
_reactKey: string
type: 'saved-feeds-loading'
numItems: number
}
| {
_reactKey: string
type: 'discover-feeds-loading'
}
| {
_reactKey: string
type: 'error'
error: string
}
| {
_reactKey: string
type: 'saved-feeds-header'
}
| {
_reactKey: string
type: 'saved-feed'
feed: FeedSourceModel
}
| {
_reactKey: string
type: 'saved-feeds-load-more'
}
| {
_reactKey: string
type: 'discover-feeds-header'
}
| {
_reactKey: string
type: 'discover-feeds-no-results'
}
| {
_reactKey: string
type: 'discover-feed'
feed: FeedSourceModel
}
export class MyFeedsUIModel {
saved: SavedFeedsModel
discovery: FeedsDiscoveryModel
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this)
this.saved = new SavedFeedsModel(this.rootStore)
this.discovery = new FeedsDiscoveryModel(this.rootStore)
}
get isRefreshing() {
return !this.saved.isLoading && this.saved.isRefreshing
}
get isLoading() {
return this.saved.isLoading || this.discovery.isLoading
}
async setup() {
if (!this.saved.hasLoaded) {
await this.saved.refresh()
}
if (!this.discovery.hasLoaded) {
await this.discovery.refresh()
}
}
clear() {
this.saved.clear()
this.discovery.clear()
}
registerListeners() {
const dispose1 = reaction(
() => this.rootStore.preferences.savedFeeds,
() => this.saved.refresh(),
)
const dispose2 = reaction(
() => this.rootStore.preferences.pinnedFeeds,
() => this.saved.refresh(),
)
return () => {
dispose1()
dispose2()
}
}
async refresh() {
return Promise.all([this.saved.refresh(), this.discovery.refresh()])
}
async loadMore() {
return this.discovery.loadMore()
}
get items() {
let items: MyFeedsItem[] = []
items.push({
_reactKey: '__saved_feeds_header__',
type: 'saved-feeds-header',
})
if (this.saved.isLoading && !this.saved.hasContent) {
items.push({
_reactKey: '__saved_feeds_loading__',
type: 'saved-feeds-loading',
numItems: this.rootStore.preferences.savedFeeds.length || 3,
})
} else if (this.saved.hasError) {
items.push({
_reactKey: '__saved_feeds_error__',
type: 'error',
error: this.saved.error,
})
} else {
const savedSorted = this.saved.all
.slice()
.sort((a, b) => a.displayName.localeCompare(b.displayName))
items = items.concat(
savedSorted.map(feed => ({
_reactKey: `saved-${feed.uri}`,
type: 'saved-feed',
feed,
})),
)
items.push({
_reactKey: '__saved_feeds_load_more__',
type: 'saved-feeds-load-more',
})
}
items.push({
_reactKey: '__discover_feeds_header__',
type: 'discover-feeds-header',
})
if (this.discovery.isLoading && !this.discovery.hasContent) {
items.push({
_reactKey: '__discover_feeds_loading__',
type: 'discover-feeds-loading',
})
} else if (this.discovery.hasError) {
items.push({
_reactKey: '__discover_feeds_error__',
type: 'error',
error: this.discovery.error,
})
} else if (this.discovery.isEmpty) {
items.push({
_reactKey: '__discover_feeds_no_results__',
type: 'discover-feeds-no-results',
})
} else {
items = items.concat(
this.discovery.feeds.map(feed => ({
_reactKey: `discover-${feed.uri}`,
type: 'discover-feed',
feed,
})),
)
if (this.discovery.isLoading) {
items.push({
_reactKey: '__discover_feeds_loading_more__',
type: 'spinner',
})
}
}
return items
}
}
-557
View File
@@ -1,557 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
LabelPreference as APILabelPreference,
BskyFeedViewPreference,
BskyThreadViewPreference,
} from '@atproto/api'
import AwaitLock from 'await-lock'
import isEqual from 'lodash.isequal'
import {isObj, hasProp} from 'lib/type-guards'
import {RootStoreModel} from '../root-store'
import {ModerationOpts} from '@atproto/api'
import {DEFAULT_FEEDS} from 'lib/constants'
import {getAge} from 'lib/strings/time'
import {FeedTuner} from 'lib/api/feed-manip'
import {logger} from '#/logger'
import {getContentLanguages} from '#/state/preferences/languages'
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
export type LabelPreference = APILabelPreference | 'show'
export type FeedViewPreference = BskyFeedViewPreference & {
lab_mergeFeedEnabled?: boolean | undefined
}
export type ThreadViewPreference = BskyThreadViewPreference & {
lab_treeViewEnabled?: boolean | undefined
}
const LABEL_GROUPS = [
'nsfw',
'nudity',
'suggestive',
'gore',
'hate',
'spam',
'impersonation',
]
const VISIBILITY_VALUES = ['ignore', 'warn', 'hide']
const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random']
interface LegacyPreferences {
hideReplies?: boolean
hideRepliesByLikeCount?: number
hideReposts?: boolean
hideQuotePosts?: boolean
}
export class LabelPreferencesModel {
nsfw: LabelPreference = 'hide'
nudity: LabelPreference = 'warn'
suggestive: LabelPreference = 'warn'
gore: LabelPreference = 'warn'
hate: LabelPreference = 'hide'
spam: LabelPreference = 'hide'
impersonation: LabelPreference = 'warn'
constructor() {
makeAutoObservable(this, {}, {autoBind: true})
}
}
export class PreferencesModel {
adultContentEnabled = false
contentLabels = new LabelPreferencesModel()
savedFeeds: string[] = []
pinnedFeeds: string[] = []
birthDate: Date | undefined = undefined
homeFeed: FeedViewPreference = {
hideReplies: false,
hideRepliesByUnfollowed: false,
hideRepliesByLikeCount: 0,
hideReposts: false,
hideQuotePosts: false,
lab_mergeFeedEnabled: false, // experimental
}
thread: ThreadViewPreference = {
sort: 'oldest',
prioritizeFollowedUsers: true,
lab_treeViewEnabled: false, // experimental
}
// used to help with transitions from device-stored to server-stored preferences
legacyPreferences: LegacyPreferences | undefined
// used to linearize async modifications to state
lock = new AwaitLock()
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {lock: false}, {autoBind: true})
}
get userAge(): number | undefined {
if (!this.birthDate) {
return undefined
}
return getAge(this.birthDate)
}
serialize() {
return {
contentLabels: this.contentLabels,
savedFeeds: this.savedFeeds,
pinnedFeeds: this.pinnedFeeds,
}
}
/**
* The function hydrates an object with properties related to content languages, labels, saved feeds,
* and pinned feeds that it gets from the parameter `v` (probably local storage)
* @param {unknown} v - the data object to hydrate from
*/
hydrate(v: unknown) {
if (isObj(v)) {
// check if content labels in preferences exist, then hydrate
if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') {
Object.assign(this.contentLabels, v.contentLabels)
}
// check if saved feeds in preferences, then hydrate
if (
hasProp(v, 'savedFeeds') &&
Array.isArray(v.savedFeeds) &&
typeof v.savedFeeds.every(item => typeof item === 'string')
) {
this.savedFeeds = v.savedFeeds
}
// check if pinned feeds in preferences exist, then hydrate
if (
hasProp(v, 'pinnedFeeds') &&
Array.isArray(v.pinnedFeeds) &&
typeof v.pinnedFeeds.every(item => typeof item === 'string')
) {
this.pinnedFeeds = v.pinnedFeeds
}
// grab legacy values
this.legacyPreferences = getLegacyPreferences(v)
}
}
/**
* This function fetches preferences and sets defaults for missing items.
*/
async sync() {
await this.lock.acquireAsync()
try {
// fetch preferences
const prefs = await this.rootStore.agent.getPreferences()
runInAction(() => {
if (prefs.feedViewPrefs.home) {
this.homeFeed = prefs.feedViewPrefs.home
}
this.thread = prefs.threadViewPrefs
this.adultContentEnabled = prefs.adultContentEnabled
for (const label in prefs.contentLabels) {
if (
LABEL_GROUPS.includes(label) &&
VISIBILITY_VALUES.includes(prefs.contentLabels[label])
) {
this.contentLabels[label as keyof LabelPreferencesModel] =
prefs.contentLabels[label]
}
}
if (prefs.feeds.saved && !isEqual(this.savedFeeds, prefs.feeds.saved)) {
this.savedFeeds = prefs.feeds.saved
}
if (
prefs.feeds.pinned &&
!isEqual(this.pinnedFeeds, prefs.feeds.pinned)
) {
this.pinnedFeeds = prefs.feeds.pinned
}
this.birthDate = prefs.birthDate
})
// sync legacy values if needed
await this.syncLegacyPreferences()
// set defaults on missing items
if (typeof prefs.feeds.saved === 'undefined') {
try {
const {saved, pinned} = await DEFAULT_FEEDS(
this.rootStore.agent.service.toString(),
(handle: string) =>
this.rootStore.agent
.resolveHandle({handle})
.then(({data}) => data.did),
)
runInAction(() => {
this.savedFeeds = saved
this.pinnedFeeds = pinned
})
await this.rootStore.agent.setSavedFeeds(saved, pinned)
} catch (error) {
logger.error('Failed to set default feeds', {error})
}
}
} finally {
this.lock.release()
}
}
async syncLegacyPreferences() {
if (this.legacyPreferences) {
this.homeFeed = {...this.homeFeed, ...this.legacyPreferences}
this.legacyPreferences = undefined
await this.rootStore.agent.setFeedViewPrefs('home', this.homeFeed)
}
}
/**
* This function resets the preferences to an empty array of no preferences.
*/
async reset() {
await this.lock.acquireAsync()
try {
runInAction(() => {
this.contentLabels = new LabelPreferencesModel()
this.savedFeeds = []
this.pinnedFeeds = []
})
await this.rootStore.agent.app.bsky.actor.putPreferences({
preferences: [],
})
} finally {
this.lock.release()
}
}
// moderation
// =
async setContentLabelPref(
key: keyof LabelPreferencesModel,
value: LabelPreference,
) {
this.contentLabels[key] = value
await this.rootStore.agent.setContentLabelPref(key, value)
}
async setAdultContentEnabled(v: boolean) {
this.adultContentEnabled = v
await this.rootStore.agent.setAdultContentEnabled(v)
}
get moderationOpts(): ModerationOpts {
return {
userDid: this.rootStore.session.currentSession?.did || '',
adultContentEnabled: this.adultContentEnabled,
labels: {
// TEMP translate old settings until this UI can be migrated -prf
porn: tempfixLabelPref(this.contentLabels.nsfw),
sexual: tempfixLabelPref(this.contentLabels.suggestive),
nudity: tempfixLabelPref(this.contentLabels.nudity),
nsfl: tempfixLabelPref(this.contentLabels.gore),
corpse: tempfixLabelPref(this.contentLabels.gore),
gore: tempfixLabelPref(this.contentLabels.gore),
torture: tempfixLabelPref(this.contentLabels.gore),
'self-harm': tempfixLabelPref(this.contentLabels.gore),
'intolerant-race': tempfixLabelPref(this.contentLabels.hate),
'intolerant-gender': tempfixLabelPref(this.contentLabels.hate),
'intolerant-sexual-orientation': tempfixLabelPref(
this.contentLabels.hate,
),
'intolerant-religion': tempfixLabelPref(this.contentLabels.hate),
intolerant: tempfixLabelPref(this.contentLabels.hate),
'icon-intolerant': tempfixLabelPref(this.contentLabels.hate),
spam: tempfixLabelPref(this.contentLabels.spam),
impersonation: tempfixLabelPref(this.contentLabels.impersonation),
scam: 'warn',
},
labelers: [
{
labeler: {
did: '',
displayName: 'Bluesky Social',
},
labels: {},
},
],
}
}
// feeds
// =
isPinnedFeed(uri: string) {
return this.pinnedFeeds.includes(uri)
}
async _optimisticUpdateSavedFeeds(
saved: string[],
pinned: string[],
cb: () => Promise<{saved: string[]; pinned: string[]}>,
) {
const oldSaved = this.savedFeeds
const oldPinned = this.pinnedFeeds
this.savedFeeds = saved
this.pinnedFeeds = pinned
await this.lock.acquireAsync()
try {
const res = await cb()
runInAction(() => {
this.savedFeeds = res.saved
this.pinnedFeeds = res.pinned
})
} catch (e) {
runInAction(() => {
this.savedFeeds = oldSaved
this.pinnedFeeds = oldPinned
})
throw e
} finally {
this.lock.release()
}
}
async setSavedFeeds(saved: string[], pinned: string[]) {
return this._optimisticUpdateSavedFeeds(saved, pinned, () =>
this.rootStore.agent.setSavedFeeds(saved, pinned),
)
}
async addSavedFeed(v: string) {
return this._optimisticUpdateSavedFeeds(
[...this.savedFeeds.filter(uri => uri !== v), v],
this.pinnedFeeds,
() => this.rootStore.agent.addSavedFeed(v),
)
}
async removeSavedFeed(v: string) {
return this._optimisticUpdateSavedFeeds(
this.savedFeeds.filter(uri => uri !== v),
this.pinnedFeeds.filter(uri => uri !== v),
() => this.rootStore.agent.removeSavedFeed(v),
)
}
async addPinnedFeed(v: string) {
return this._optimisticUpdateSavedFeeds(
[...this.savedFeeds.filter(uri => uri !== v), v],
[...this.pinnedFeeds.filter(uri => uri !== v), v],
() => this.rootStore.agent.addPinnedFeed(v),
)
}
async removePinnedFeed(v: string) {
return this._optimisticUpdateSavedFeeds(
this.savedFeeds,
this.pinnedFeeds.filter(uri => uri !== v),
() => this.rootStore.agent.removePinnedFeed(v),
)
}
// other
// =
async setBirthDate(birthDate: Date) {
this.birthDate = birthDate
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setPersonalDetails({birthDate})
} finally {
this.lock.release()
}
}
async toggleHomeFeedHideReplies() {
this.homeFeed.hideReplies = !this.homeFeed.hideReplies
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
hideReplies: this.homeFeed.hideReplies,
})
} finally {
this.lock.release()
}
}
async toggleHomeFeedHideRepliesByUnfollowed() {
this.homeFeed.hideRepliesByUnfollowed =
!this.homeFeed.hideRepliesByUnfollowed
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
})
} finally {
this.lock.release()
}
}
async setHomeFeedHideRepliesByLikeCount(threshold: number) {
this.homeFeed.hideRepliesByLikeCount = threshold
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
})
} finally {
this.lock.release()
}
}
async toggleHomeFeedHideReposts() {
this.homeFeed.hideReposts = !this.homeFeed.hideReposts
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
hideReposts: this.homeFeed.hideReposts,
})
} finally {
this.lock.release()
}
}
async toggleHomeFeedHideQuotePosts() {
this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
hideQuotePosts: this.homeFeed.hideQuotePosts,
})
} finally {
this.lock.release()
}
}
async toggleHomeFeedMergeFeedEnabled() {
this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
})
} finally {
this.lock.release()
}
}
async setThreadSort(v: string) {
if (THREAD_SORT_VALUES.includes(v)) {
this.thread.sort = v
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setThreadViewPrefs({sort: v})
} finally {
this.lock.release()
}
}
}
async togglePrioritizedFollowedUsers() {
this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setThreadViewPrefs({
prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
})
} finally {
this.lock.release()
}
}
async toggleThreadTreeViewEnabled() {
this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setThreadViewPrefs({
lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
})
} finally {
this.lock.release()
}
}
getFeedTuners(
feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes',
) {
if (feedType === 'custom') {
return [
FeedTuner.dedupReposts,
FeedTuner.preferredLangOnly(getContentLanguages()),
]
}
if (feedType === 'list') {
return [FeedTuner.dedupReposts]
}
if (feedType === 'home' || feedType === 'following') {
const feedTuners = []
if (this.homeFeed.hideReposts) {
feedTuners.push(FeedTuner.removeReposts)
} else {
feedTuners.push(FeedTuner.dedupReposts)
}
if (this.homeFeed.hideReplies) {
feedTuners.push(FeedTuner.removeReplies)
} else {
feedTuners.push(
FeedTuner.thresholdRepliesOnly({
userDid: this.rootStore.session.data?.did || '',
minLikes: this.homeFeed.hideRepliesByLikeCount,
followedOnly: !!this.homeFeed.hideRepliesByUnfollowed,
}),
)
}
if (this.homeFeed.hideQuotePosts) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
return feedTuners
}
return []
}
}
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
function tempfixLabelPref(pref: LabelPreference): APILabelPreference {
if (pref === 'show') {
return 'ignore'
}
return pref
}
function getLegacyPreferences(
v: Record<string, unknown>,
): LegacyPreferences | undefined {
const legacyPreferences: LegacyPreferences = {}
if (
hasProp(v, 'homeFeedRepliesEnabled') &&
typeof v.homeFeedRepliesEnabled === 'boolean'
) {
legacyPreferences.hideReplies = !v.homeFeedRepliesEnabled
}
if (
hasProp(v, 'homeFeedRepliesThreshold') &&
typeof v.homeFeedRepliesThreshold === 'number'
) {
legacyPreferences.hideRepliesByLikeCount = v.homeFeedRepliesThreshold
}
if (
hasProp(v, 'homeFeedRepostsEnabled') &&
typeof v.homeFeedRepostsEnabled === 'boolean'
) {
legacyPreferences.hideReposts = !v.homeFeedRepostsEnabled
}
if (
hasProp(v, 'homeFeedQuotePostsEnabled') &&
typeof v.homeFeedQuotePostsEnabled === 'boolean'
) {
legacyPreferences.hideQuotePosts = !v.homeFeedQuotePostsEnabled
}
if (Object.keys(legacyPreferences).length) {
return legacyPreferences
}
return undefined
}
-257
View File
@@ -1,257 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from '../root-store'
import {ProfileModel} from '../content/profile'
import {PostsFeedModel} from '../feeds/posts'
import {ActorFeedsModel} from '../lists/actor-feeds'
import {ListsListModel} from '../lists/lists-list'
import {logger} from '#/logger'
export enum Sections {
PostsNoReplies = 'Posts',
PostsWithReplies = 'Posts & replies',
PostsWithMedia = 'Media',
Likes = 'Likes',
CustomAlgorithms = 'Feeds',
Lists = 'Lists',
}
export interface ProfileUiParams {
user: string
}
export class ProfileUiModel {
static LOADING_ITEM = {_reactKey: '__loading__'}
static END_ITEM = {_reactKey: '__end__'}
static EMPTY_ITEM = {_reactKey: '__empty__'}
isAuthenticatedUser = false
// data
profile: ProfileModel
feed: PostsFeedModel
algos: ActorFeedsModel
lists: ListsListModel
// ui state
selectedViewIndex = 0
constructor(
public rootStore: RootStoreModel,
public params: ProfileUiParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.profile = new ProfileModel(rootStore, {actor: params.user})
this.feed = new PostsFeedModel(rootStore, 'author', {
actor: params.user,
limit: 10,
filter: 'posts_no_replies',
})
this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
this.lists = new ListsListModel(rootStore, params.user)
}
get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
if (
this.selectedView === Sections.PostsNoReplies ||
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.PostsWithMedia ||
this.selectedView === Sections.Likes
) {
return this.feed
} else if (this.selectedView === Sections.Lists) {
return this.lists
}
if (this.selectedView === Sections.CustomAlgorithms) {
return this.algos
}
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
}
get isInitialLoading() {
const view = this.currentView
return view.isLoading && !view.isRefreshing && !view.hasContent
}
get isRefreshing() {
return this.profile.isRefreshing || this.currentView.isRefreshing
}
get selectorItems() {
const items = [
Sections.PostsNoReplies,
Sections.PostsWithReplies,
Sections.PostsWithMedia,
this.isAuthenticatedUser && Sections.Likes,
].filter(Boolean) as string[]
if (this.algos.hasLoaded && !this.algos.isEmpty) {
items.push(Sections.CustomAlgorithms)
}
if (this.lists.hasLoaded && !this.lists.isEmpty) {
items.push(Sections.Lists)
}
return items
}
get selectedView() {
// If, for whatever reason, the selected view index is not available, default back to posts
// This can happen when the user was focused on a view but performed an action that caused
// the view to disappear (e.g. deleting the last list in their list of lists https://imgflip.com/i/7txu1y)
return this.selectorItems[this.selectedViewIndex] || Sections.PostsNoReplies
}
get uiItems() {
let arr: any[] = []
// if loading, return loading item to show loading spinner
if (this.isInitialLoading) {
arr = arr.concat([ProfileUiModel.LOADING_ITEM])
} else if (this.currentView.hasError) {
// if error, return error item to show error message
arr = arr.concat([
{
_reactKey: '__error__',
error: this.currentView.error,
},
])
} else {
if (
this.selectedView === Sections.PostsNoReplies ||
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.PostsWithMedia ||
this.selectedView === Sections.Likes
) {
if (this.feed.hasContent) {
arr = this.feed.slices.slice()
if (!this.feed.hasMore) {
arr = arr.concat([ProfileUiModel.END_ITEM])
}
} else if (this.feed.isEmpty) {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
} else if (this.selectedView === Sections.CustomAlgorithms) {
if (this.algos.hasContent) {
arr = this.algos.feeds
} else if (this.algos.isEmpty) {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
} else if (this.selectedView === Sections.Lists) {
if (this.lists.hasContent) {
arr = this.lists.lists
} else if (this.lists.isEmpty) {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
} else {
// fallback, add empty item, to show empty message
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
}
return arr
}
get showLoadingMoreFooter() {
if (
this.selectedView === Sections.PostsNoReplies ||
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.PostsWithMedia ||
this.selectedView === Sections.Likes
) {
return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
} else if (this.selectedView === Sections.Lists) {
return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading
}
return false
}
// public api
// =
setSelectedViewIndex(index: number) {
// ViewSelector fires onSelectView on mount
if (index === this.selectedViewIndex) return
this.selectedViewIndex = index
if (
this.selectedView === Sections.PostsNoReplies ||
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.PostsWithMedia
) {
let filter = 'posts_no_replies'
if (this.selectedView === Sections.PostsWithReplies) {
filter = 'posts_with_replies'
} else if (this.selectedView === Sections.PostsWithMedia) {
filter = 'posts_with_media'
}
this.feed = new PostsFeedModel(
this.rootStore,
'author',
{
actor: this.params.user,
limit: 10,
filter,
},
{
isSimpleFeed: ['posts_with_media'].includes(filter),
},
)
this.feed.setup()
} else if (this.selectedView === Sections.Likes) {
this.feed = new PostsFeedModel(
this.rootStore,
'likes',
{
actor: this.params.user,
limit: 10,
},
{
isSimpleFeed: true,
},
)
this.feed.setup()
}
}
async setup() {
await Promise.all([
this.profile
.setup()
.catch(err => logger.error('Failed to fetch profile', {error: err})),
this.feed
.setup()
.catch(err => logger.error('Failed to fetch feed', {error: err})),
])
runInAction(() => {
this.isAuthenticatedUser =
this.profile.did === this.rootStore.session.currentSession?.did
})
this.algos.refresh()
// HACK: need to use the DID as a param, not the username -prf
this.lists.source = this.profile.did
this.lists
.loadMore()
.catch(err => logger.error('Failed to fetch lists', {error: err}))
}
async refresh() {
await Promise.all([this.profile.refresh(), this.currentView.refresh()])
}
async loadMore() {
if (
!this.currentView.isLoading &&
!this.currentView.hasError &&
!this.currentView.isEmpty
) {
await this.currentView.loadMore()
}
}
}

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