Compare commits

..

146 Commits

Author SHA1 Message Date
Eric Bailey 51616fa63c Revert debug code 2023-12-05 14:48:28 -06:00
Eric Bailey 51a6590f50 Better skip for merge feed 2023-12-05 14:39:41 -06:00
Eric Bailey 3f3765c067 Fix thread view 2023-12-05 14:14:03 -06:00
Eric Bailey 60c72e8f15 Fix two more 2023-12-05 13:41:07 -06:00
Eric Bailey d2778766a9 Keep using globals 2023-12-05 13:36:58 -06:00
Eric Bailey 3117700f4e Fix profile test with hack 2023-12-05 11:20:00 -06:00
Eric Bailey e5a787d9ea Fix profile tests 2023-12-05 11:13:25 -06:00
Eric Bailey 991411f3e6 Fix app view url 2023-12-05 11:13:25 -06:00
Eric Bailey a4c6a25586 Fix mod lists 2023-12-05 11:13:25 -06:00
Eric Bailey 7b5be58c8f Give up on mergefeed test 2023-12-05 11:13:25 -06:00
Eric Bailey a7301a2ac6 Fix curate-lists tests 2023-12-05 11:13:25 -06:00
Eric Bailey e2041d0372 Fix invite codes test 2023-12-05 11:13:25 -06:00
Eric Bailey 27c674a78d Fix curate-lists tests, split in two 2023-12-05 11:13:25 -06:00
Eric Bailey 276fbf9f19 Fix composer tests 2023-12-05 11:13:25 -06:00
Eric Bailey cb256f651d Fix home screen tests 2023-12-05 11:13:25 -06:00
Eric Bailey 1718816203 Upgrade dev-env 2023-12-05 11:13:25 -06:00
Eric Bailey bcbd525a77 Fix create account test 2023-12-05 11:13:25 -06:00
Eric Bailey c1887f510f Fix log handling via env vars in expo 2023-12-05 11:13:25 -06:00
Eric Bailey 28b13cda3a Add logged out e2e ctrl, fix login test 2023-12-05 11:13:25 -06:00
dan ed5a97d0fa Fix jump when toggling suggestions (#2090) 2023-12-05 09:13:09 -08:00
Paul Frazee 37d94ca0e3 Bump ios build number 2023-12-04 18:01:49 -08:00
Paul Frazee 48f5cebc80 Update feeds on post created (#2085) 2023-12-04 17:58:45 -08:00
Paul Frazee bdb2bfdd83 Rename Posts & Replies to Replies (#2087) 2023-12-04 17:56:41 -08:00
Paul Frazee 74e0b73db5 Add back ignore-filters-for behaviors (#2086) 2023-12-04 17:54:39 -08:00
Paul Frazee d07ea64434 Fix: correctly partial-match when calling truncateAndInvalidate() (#2088) 2023-12-04 17:53:22 -08:00
Paul Frazee 160055e4f9 Merge branch 'CooperEdmunds-2047-last-active-tab' into main 2023-12-04 16:33:18 -08:00
Paul Frazee 81c525f2b2 Merge branch '2047-last-active-tab' of https://github.com/CooperEdmunds/social-app into CooperEdmunds-2047-last-active-tab 2023-12-04 16:30:08 -08:00
Paul Frazee f1b69a332a Tune treeview thread mode to have consistent spacing (#2078) 2023-12-04 16:25:17 -08:00
Eric Bailey 4f171be9bf Fix bottom space on feeds (#2081)
* Fix bottom space on feeds

* Translate

* Handle web better
2023-12-04 16:24:51 -08:00
Paul Frazee 6e2eaa746d Optimistically update the pager tab bar on swipe (#2083) 2023-12-04 16:06:15 -08:00
Paul Frazee 8e2cfa52de Typefix 2023-12-04 15:08:34 -08:00
Eric Bailey f1cfea5786 Add back pinning functionality to ProfileList (#2084) 2023-12-04 15:52:51 -06:00
Eric Bailey 094a58490f Handle feedgen 429 (#2066)
* Handle feedgen 429

* Translate
2023-12-04 13:27:48 -08:00
Cooper Edmunds 17c58d40ff Track active page using string instead of index 2023-12-04 16:10:03 -05:00
Paul Frazee 37cafb080b Fix: distinguish between post media and quotes with the moderation hider (#2075)
* Fix: distinguish between post media and quotes with the moderation hider

* Type fixes
2023-12-04 12:53:25 -08:00
Paul Frazee a46059ca46 Improve the post-hiders and the header alerts (#2074)
* Improve the post-hider and the header alerts

* Adjust the contenthider (icon, font size)
2023-12-04 12:51:08 -08:00
Paul Frazee 971c402788 Add PWI build flag (#2082) 2023-12-04 12:45:40 -08:00
dan a687d3f028 Allow scroll by dragging profile header (#2068) 2023-12-04 12:03:32 -08:00
Paul Frazee 9562a80908 Allow posts with just an external link (#2079) 2023-12-04 11:59:59 -08:00
Paul Frazee e47c3dbe0e Tweak the loading placeholders to use the full space and position better (#2072) 2023-12-04 11:59:27 -08:00
Paul Frazee a01497fed4 Hackfix to composer focus on web (#2057) 2023-12-03 18:45:25 -08:00
Paul Frazee 0ab19b45a2 Tweak layout a bit more 2023-12-02 17:56:04 -08:00
Paul Frazee 07afcab6b2 Merge branch 'fix-jump-in-suggested-follows-tabbar' of https://github.com/tarikfp/social-app into uiwork-reflow-issue 2023-12-02 17:52:21 -08:00
dan 2698a6b50b Fix show suggested button color (#2069) 2023-12-01 16:23:00 -08:00
dan bb22ebd588 Fix scroll jump on Back (#2067) 2023-12-01 14:05:10 -08:00
Eric Bailey 9dec9d7276 Only poll feed when feed page is focused (#2064)
* Do no poll when screen is not focused

* Avoid polling unless focused

* Handle homepage in background

* Fix the intl:check to ignore comments in diffs

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2023-12-01 13:40:50 -08:00
Cooper Edmunds 9a13fe50cc Use lastActiveTab from AsyncStorage for non-desktop 2023-12-01 15:18:25 -05:00
Eric Bailey f80bd30ef4 Better handle feed auth failures (#2060)
* Better handle feed auth failures

* Add comment
2023-12-01 09:29:50 -08:00
Eric Bailey edc9ac272c Add Lingui check to CI (#2058)
* Install lingui eslint plugin, ignore compiled files

* Add lingui step to Lint workflow

* Run check in other job

* Add compile step to web build

* Temp: add a new string to test with

* Add message

* Revert "Temp: add a new string to test with"

This reverts commit ef5459d00c.

* Un-track compiled files

* Add check scripts to package.json, build step

* Make sure to compile translations prior to test/typecheck

* Rename scripts
2023-12-01 09:14:10 -08:00
Paul Frazee b05cf9f3c1 Bump ios build number 2023-11-30 18:57:18 -08:00
Paul Frazee f8c46c08ca Improve unread notif tracking (#2056) 2023-11-30 18:56:55 -08:00
Paul Frazee 826cbbd4bf Maintain some feed data to avoid needless glimmers (#2054) 2023-11-30 18:49:23 -08:00
dan 9fa90bb8d9 Optimize pager rendering (#2055)
* Pull out and memoize PagerTabBar

* Avoid invalidating onScroll and add throttling

* Make isScrolledDown update non-blocking

* Fix types
2023-12-01 02:11:05 +00:00
Paul Frazee 23ad3ad98b Various fixes (#2053)
* Fix invite code translations (plural api not availabe in ios)

* Speed up the composer animation a smidge
2023-11-30 17:03:38 -08:00
Cλctys 5839d2a30c Fix scuffed web styles caused by overlapping viewport breakpoint boundaries (#1985)
* fixed lack of styles on 1300px web viewport width by adjusting tablet breakpoints

* fixed lack of styles on 800px web viewport width by adjusting mobile breakpoints

* changed `maxWidth` values in viewports to `n - 1` format
2023-11-30 16:14:36 -08:00
Paul Frazee 85b2b390df Merge branch 'mozzius-invite-code-warning' into main 2023-11-30 16:13:23 -08:00
Paul Frazee 1755c2e538 Merge branch 'invite-code-warning' of https://github.com/mozzius/bluesky-official-app into mozzius-invite-code-warning 2023-11-30 16:13:16 -08:00
Paul Frazee dcbceb5d9f Merge branch 'mozzius-copy-link' into main 2023-11-30 16:06:07 -08:00
Paul Frazee 21af10bc3d Merge branch 'copy-link' of https://github.com/mozzius/bluesky-official-app into mozzius-copy-link 2023-11-30 16:05:34 -08:00
Samuel Newman 9375532698 Add enter/exit animations to modals on web (#2042)
* add reanimated layout animations to modals

* reorder imports

* pass context to scroll handler patches
2023-11-30 16:02:46 -08:00
Eric Bailey abe61b7517 Disable OTA for now (#2052) 2023-11-30 15:48:07 -08:00
Eric Bailey 28f8999252 Beep boop (#2051) 2023-11-30 13:53:34 -08:00
Eric Bailey 76a3c66f54 Eric/profile feeds list (#2049)
* Fix profile feedgens view

* Fix profile lists view

* Translate
2023-11-30 13:40:49 -08:00
Eric Bailey 47b0d36b73 Hide normal mute if muted by list, and invalidate profile query upon list mute (#2048)
* Hide normal mute if muted by list, and invalidate profile query upon list mute

* Prevent profile flashing
2023-11-30 13:40:01 -08:00
dan 46b63accb8 Rewrite the shadow logic to look inside the cache (#2045)
* Reset

* Associate shadows with the cache

* Use colocated helpers

* Fix types

* Reorder for clarity

* More types

* Copy paste logic for profile

* Hook up profile query

* Hook up suggested follows

* Hook up other profile things

* Fix shape

* Pass setShadow into the effect deps

* Include reply posts in the shadow cache search

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2023-11-30 13:35:58 -08:00
Paul Frazee 143fc80951 Merge branch 'main' of github.com:bluesky-social/social-app into main 2023-11-30 12:47:48 -08:00
Paul Frazee ee82e78d5c Fix missing export in e2e version 2023-11-30 12:47:40 -08:00
dan cb193a827d Fix lag when switching tabs (#2046) 2023-11-30 19:57:54 +00:00
Samuel Newman b7599e5bea generate messages 2023-11-30 17:12:21 +00:00
Samuel Newman b164f151cc Merge remote-tracking branch 'upstream/main' into invite-code-warning 2023-11-30 17:11:51 +00:00
Samuel Newman ee5d7a63ce update messages 2023-11-30 17:09:59 +00:00
Samuel Newman c06611fb71 show informative message when token scope is wrong 2023-11-30 17:09:10 +00:00
Samuel Newman 5c78b2266f add "copy link" text 2023-11-30 16:40:19 +00:00
Paul Frazee fcd22d4ccb Adjust stale-caches and dont group read&unread notifs together (#2041)
* Dont group read & unread notifications together

* Remove and reduce some stale cache times

* Keep the staleTime on the post-feed

* Bring back the load-bearing staletime on profile
2023-11-29 20:27:39 -08:00
Paul Frazee c3e97fee1d Bump ios build number 2023-11-29 20:12:55 -08:00
Paul Frazee a03f57c8c3 Apply feed preferences (react-query refactor) (#2040)
* Actually implement the feed tuners hook

* Move feed-tuner pass into select() to have it apply immediately on change
2023-11-29 20:11:01 -08:00
Eric Bailey 3e1b2346ee Close modals if switching to unauthed account (#2039) 2023-11-29 19:28:18 -08:00
Eric Bailey b83fa95793 Handle nsfw feeds in public view (#2035)
* Handle nsfw feeds in public view

* Translate

* Refactor, new approach

* Translate

* Copy

* Translate

* Revert changes to Feed

* Remove unused retry

* Translate
2023-11-29 19:27:58 -08:00
Eric Bailey 005fc7cc38 Disable retrying globally (#2038) 2023-11-29 17:50:14 -08:00
Paul Frazee c07d27645f Merge branch 'CooperEdmunds-2011-link-in-feeds-tab-bar' into main 2023-11-29 17:49:46 -08:00
Paul Frazee 1140b05b83 Slightly less verbose variable 2023-11-29 17:37:15 -08:00
Paul Frazee 4c7b9652db Merge branch '2011-link-in-feeds-tab-bar' of https://github.com/CooperEdmunds/social-app into CooperEdmunds-2011-link-in-feeds-tab-bar 2023-11-29 17:35:57 -08:00
Paul Frazee dc53ef1ec1 Merge branch 'CooperEdmunds-2011-default-pin-feed-on-add' into main 2023-11-29 17:25:36 -08:00
Paul Frazee 6e30c339da Merge branch '2011-default-pin-feed-on-add' of https://github.com/CooperEdmunds/social-app into CooperEdmunds-2011-default-pin-feed-on-add 2023-11-29 17:25:29 -08:00
Paul Frazee 630637874d Fix state lifecycle management with post-feed query, solving the duplicate key issue (#2034)
* Assign keys to feed slices via a counter, to enable duplicate items in the feed if needed

* Move post-feed query state into the query's page params to consistently bind their lifecycles
2023-11-29 18:58:14 -06:00
Cooper Edmunds 3fbac466ac Update home-screen tests related to feeds 2023-11-29 19:28:35 -05:00
Eric Bailey a59d235e8b Improve feed reordering with optimistic updates (#2032)
* Optimisticaly update order of saved feeds

* Better show disabled state for pin button

* Improve loading/disabled states

* Improve placeholder

* Simplify loading components
2023-11-29 16:17:27 -08:00
Eric Bailey 3ca4bd805a Re-enable fetch monkey-patch (#2036)
* Re-enable fetch monkey-patch

* Reorder
2023-11-29 16:00:13 -08:00
Cooper Edmunds 34759798eb Stop adding whats-hot for new users 2023-11-29 15:24:14 -05:00
Cooper Edmunds 8ceabe2a11 Conditionally render feeds link in feeds tab bar 2023-11-29 14:49:18 -05:00
Cooper Edmunds ed391c346d Add hasPinnedCustomFeedOrList to usePinnedFeedsInfos hook 2023-11-29 14:23:19 -05:00
Paul Frazee 4b3ec54add Fix infinite query reloading behavior (reset, not invalidate) (#2031)
* Reset, not invalidate, notification queries

* Reset, not invalidate, feed queries
2023-11-29 11:15:35 -08:00
Paul Frazee 9239efac9c Refactor the notifications to cache and reuse results from the unread-notifs checks (#2017)
* Refactor the notifications to cache and reuse results from the unread-notifs checks

* Fix types
2023-11-29 10:20:14 -08:00
Ansh 620e002841 Show logged out view when adding accounts (#2020)
* show logged out view when adding accounts

* Handle existing signed-in account

* Show which account is currently logged in

* Fix showing toasts

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2023-11-29 10:11:06 -08:00
Paul Frazee 6fe2b52f68 Get more rigorous about getAgent() consistency (#2026)
* Get more rigorous about getAgent() consistency

* Update the feed wrapper API to use getAgent() directly
2023-11-29 10:10:04 -08:00
Cooper Edmunds ca357ecbcf Add pinOnSave and use it in discover feeds list 2023-11-29 09:05:26 -05:00
dan 9fb2c29c67 Disable RQ structural sharing (#2022) 2023-11-28 20:48:08 -08:00
Eric Bailey 6f7032d42b Full send Sentry (#2018)
* Update build profiles, sentry config

* Enable sentry

* Ok actually enable in dev

* Remove debug

* Add TF build

* Fix typo

* Remove debug

* Remove unecessary config

* Fix typo

* Set env in Expo

* Remove scripts

* Clarify

* Replace invalid character

* Align on release/dist

* Add build version

* Just use package version

* Align dist
2023-11-28 21:49:37 -06:00
dan b778017000 Fix memory leak on mobile web tab navigation (#2021)
* Add navigationAction prop to Link

* Bottom bar should use navigate() action
2023-11-28 22:48:00 +00:00
Eric Bailey bb9d340d42 Fix pinned feeds mutation issue (#2016) 2023-11-28 12:50:41 -06:00
Eric Bailey 0b2c85b967 Fix pinned feeds re-ordering (#2014) 2023-11-28 09:04:15 -08:00
Eric Bailey bdabfa9d38 Guard against following tab stub being used un-authed (#2013) 2023-11-28 08:44:25 -08:00
Eric Bailey cf1b0b1881 Handle other feed auth response (#2012) 2023-11-28 08:43:25 -08:00
Paul Frazee 60886b76c8 Bump ios build number 2023-11-27 17:46:13 -08:00
Eric Bailey 17a183514a Ensure we use following feed if merge is disabled (#2005) 2023-11-27 17:42:02 -08:00
Paul Frazee f580d4daf0 Restore post-thread caching behaviors (react-query refactor) (#2010)
* Rework resolve-did and resolve-uri queries to be smarter about cache reuse

* Precache handle resolutions

* Remove old unused code

* Load placeholder threads from the post-feed and notifications-feed queries

* Remove logs

* Fix bad ref

* Add loading spinners to the cache-loading thread view

* Scroll replies into view when loading threads

* Add caching within a thread

* Fix: dont show bottom border when the child spinner is active
2023-11-27 17:41:30 -08:00
Eric Bailey d4714baf13 [PWI] Home (#2007)
* Fix up home page

* Fix spacing

* Remove unused default tab

* Update logic
2023-11-27 17:17:09 -08:00
Eric Bailey a741a201b9 Don't check unread if session doesn't exist (#2008) 2023-11-27 16:40:20 -08:00
Eric Bailey 26bbe07068 Add more debugging to auth (#2009) 2023-11-27 16:39:53 -08:00
Eric Bailey 5fd2d36273 Filter out blocked posts from thread view (#2006) 2023-11-27 12:41:53 -08:00
Paul Frazee cd43adf698 Two fixes to react-key generation (#2004)
* Fix missing react keys in ListMembers

* Fix react key construction for the posts feed
2023-11-27 12:30:41 -08:00
Eric Bailey 1dc017367e Make sure logger serializes errors (#2001) 2023-11-27 10:28:21 -08:00
Eric Bailey 6758755316 Nav home after switch accounts (#2002) 2023-11-27 10:27:38 -08:00
Eric Bailey 828e53d533 Fix soft reset scroll on profile (#1998) 2023-11-27 09:43:05 -06:00
Ansh 0079030244 create build v1.57 (#2000) 2023-11-26 13:46:31 -08:00
Eric Bailey 08355e5166 Raise levels of some logs during pre-release (#1994) 2023-11-26 13:44:30 -08:00
Eric Bailey 4a59227459 Fix translation strings (#1995) 2023-11-26 13:44:17 -08:00
Eric Bailey 3007c1dc8a Fix bad references in account switcher (#1999) 2023-11-26 13:44:08 -08:00
Eric Bailey 486fb72798 Fix double border, improve load state (#1997) 2023-11-26 13:43:49 -08:00
Eric Bailey e9a11114d3 [PWI] Clarify different ways of clearing current account/logout (#1991)
* Clarify different ways of clearing current account/logout

* Reorder log
2023-11-24 17:41:42 -06:00
Eric Bailey 20b699a008 Fall back for non-public feeds (#1988) 2023-11-24 17:37:28 -06:00
Eric Bailey 1bcbc0cf2a Fix onboarding web layout (#1990) 2023-11-24 16:40:07 -06:00
dan f2d164ec23 PWI: Refactor Shell (#1989)
* Vendor createNativeStackNavigator for further tweaks

* Completely disable withAuthRequired

* Render LoggedOut for protected routes

* Move web shell into the navigator

* Simplify the logic

* Add login modal

* Delete withAuthRequired

* Reset app state on session change

* Move TS suppression
2023-11-24 22:31:33 +00:00
Ansh 4b59a21cac Allow dynamic locale imports for web (#1971)
* allow dynamic locale imports for web

* remove unnecessary file
2023-11-24 12:35:25 -08:00
dan 43c8fb6f03 Fix missing effect cleanup (#1986) 2023-11-24 19:22:00 +00:00
dan 7ccbd14d5f Show feeds in PWI (#1984) 2023-11-23 19:43:23 +00:00
dan 4272d291a9 PWI: Profile (#1982)
* PWI: Profile

* Show replies conditionally

* Dismiss modals on auth action
2023-11-23 00:30:49 +00:00
Eric Bailey edf3114e47 Fixes 1731, compare URLs case-insensitive (#1980) 2023-11-22 17:20:35 -06:00
Eric Bailey ec819f06ce [PWI] Clean up logged out screen (#1978)
* Clean up logged out screen

* Better responsive styles
2023-11-22 16:53:25 -06:00
Eric Bailey 95f8bd8ddb [PWI] Misc (#1977)
* PostDropdownBtn

* Hide reply prompt

* Hide FABs
2023-11-22 16:53:11 -06:00
dan 08333002cc Implement "scroll to top" for profile tabs (#1973)
* Hook up scroll to top handlers

* Scroll and invalidate Feeds/Lists

* Fix index calc due to conditional tabs

* Reorder lines for clarity
2023-11-22 04:25:11 +00:00
Eric Bailey 3de1d556a9 [PWI] Feeds (#1970)
* Feeds page

* Hide save buttons on Feeds page

* ProfileFeed
2023-11-21 17:36:29 -06:00
Eric Bailey 8da10a5edc Search page (#1969) 2023-11-21 17:35:53 -06:00
Eric Bailey 47d2d3cbf2 [PWI] Shell (#1967)
* Sidebars

* Bottom bar

* Drawer

* Translate

* Spacing fix

* Fix responsive regression

* Fix nit
2023-11-21 16:58:13 -06:00
dan 4c4ba553bd Shadow refactoring and improvements (#1959)
* Make shadow a type-only concept

* Prevent unnecessary init state recalc

* Use derived state instead of effects

* Batch emitter updates

* Use object first seen time instead of dataUpdatedAt

* Stop threading dataUpdatedAt through

* Use same value consistently
2023-11-21 22:42:30 +00:00
Eric Bailey f18b9b32b0 PWI Base (#1964)
* Base work for public view

* Make default moderation settings more restrictive

* Fix type

* Handle showing sign-in on authed actions

* Fix hoc logic

* Simplify prefs logic

* Remove duplicate method

* Add todo

* Clean up RepostButton.web

* Fix x button color

* Add todo

* Retain existing label prefs for now, use separate logged out settings

* Clean up useAuthedMethod, rename to useRequireAuth

* Add todos

* Move dismiss logic to withAuthRequired

* Ooops add web

* Block public view in prod

* Add todo

* Fix bad import
2023-11-21 10:57:34 -06:00
Eric Bailey 71b59021b9 Add dev-only button to clear storage (#1965)
* Add dev-only button to clear storage

* Add legacy storage clearing too

* Use try/catch
2023-11-21 10:16:22 -06:00
Eric Bailey 3a21c02b8a Re-wrap forgot pw success in layout (#1966) 2023-11-21 10:04:23 -06:00
Ansh c5b6f88e9a Hindi Internationalization (#1914)
* get basic hindi support to work

* get web app language switcher in

* Refactor i18n implementation and remove unused
code

* add missing strings

* add dropdowns and modals missing strings

* complete all hindi translations

* fix merge conflicts

* fix legeacy persisted state

* fix data in RecommendedFeeds

* fix lint
2023-11-20 13:29:27 -08:00
Eric Bailey 019aae5f01 Improve dedupe logic on search suggestions (#1958) 2023-11-17 12:15:14 -08:00
dan 7c51a3931a Optimize notifications rendering (#1957) 2023-11-17 17:51:44 +00:00
dan d1c14940fb Add missing type (#1955) 2023-11-17 09:47:36 -08:00
Eric Bailey 0dfc039a47 Fix some key errors (#1951) 2023-11-17 11:34:27 -06:00
dan c858b58307 Do less work (#1953) 2023-11-17 09:31:26 -08:00
Eric Bailey 9c8a1b8a31 Fix some type errors (#1952)
* Fix some low-hanging type errors

* Override scrollRef types on profile
2023-11-17 11:08:45 -06:00
tarikpnr bc64c86b12 Fix jump in suggested follows tab (Native) 2023-11-08 17:07:32 +03:00
216 changed files with 14101 additions and 6293 deletions
+3
View File
@@ -1,3 +1,6 @@
# Copy this to `.env` and `.env.test` files
SENTRY_AUTH_TOKEN=
EXPO_PUBLIC_ENV=development
EXPO_PUBLIC_LOG_LEVEL=debug
EXPO_PUBLIC_LOG_DEBUG=
+3 -1
View File
@@ -7,7 +7,7 @@ module.exports = {
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'detox', 'react'],
plugins: ['@typescript-eslint', 'detox', 'react', 'lingui'],
rules: {
'react/no-unescaped-entities': 0,
'react-native/no-inline-styles': 0,
@@ -25,6 +25,8 @@ module.exports = {
'bskyweb',
'*.html',
'bskyweb',
'src/locale/locales/_build/',
'src/locale/locales/**/*.js',
],
settings: {
componentWrapperFunctions: ['observer'],
+4
View File
@@ -20,6 +20,8 @@ jobs:
run: yarn --frozen-lockfile
- name: Lint check
run: yarn lint
- name: Check & compile i18n
run: yarn intl:build
- name: Type check
run: yarn typecheck
testing:
@@ -34,6 +36,8 @@ jobs:
uses: actions/checkout@v3
- name: Yarn install
run: yarn --frozen-lockfile
- name: Check & compile i18n
run: yarn intl:build
- name: Run tests
run: |
yarn test --forceExit
+5 -1
View File
@@ -102,4 +102,8 @@ ios/
google-services.json
# Performance results (Flashlight)
.perf/
.perf/
# i18n
src/locale/locales/_build/
src/locale/locales/**/*.js
+1
View File
@@ -31,6 +31,7 @@ RUN \. "$NVM_DIR/nvm.sh" && \
nvm use $NODE_VERSION && \
npm install --global yarn && \
yarn && \
yarn intl:compile && \
yarn build-web
# DEBUG
+4
View File
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
describe('Composer', () => {
@@ -45,6 +47,8 @@ describe('Composer', () => {
})
it('Reply text only', async () => {
await element(by.id('e2eRefreshHome')).tap()
const post = by.id('feedItem-by-alice.test')
await element(by.id('replyBtn').withAncestor(post)).atIndex(0).tap()
await element(by.id('composerTextInput')).typeText('Reply text only')
+10
View File
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, createServer} from '../util'
describe('Create account', () => {
@@ -10,6 +12,8 @@ describe('Create account', () => {
})
it('I can create a new account', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('otherServerBtn')).tap()
@@ -17,14 +21,20 @@ describe('Create account', () => {
await element(by.id('customServerInput')).clearText()
await element(by.id('customServerInput')).typeText(service)
await device.takeScreenshot('3- input test server URL')
await element(by.id('nextBtn')).tap()
await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
+18 -15
View File
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Curate lists', () => {
@@ -11,7 +13,6 @@ describe('Curate lists', () => {
})
it('Login and create a curatelists', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice()
await element(by.id('e2eGotoLists')).tap()
await element(by.id('newUserListBtn')).tap()
@@ -27,7 +28,7 @@ describe('Curate lists', () => {
it('Edit display name and description via the edit curatelist modal', async () => {
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit List Details')).tap()
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editNameInput')).clearText()
await element(by.id('editNameInput')).typeText('Bad Ppl')
@@ -45,7 +46,7 @@ describe('Curate lists', () => {
it('Remove description via the edit curatelist modal', async () => {
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit List Details')).tap()
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editDescriptionInput')).clearText()
await element(by.id('saveBtn')).tap()
@@ -60,7 +61,7 @@ describe('Curate lists', () => {
it('Set avi via the edit curatelist modal', async () => {
await expect(element(by.id('userAvatarFallback'))).toExist()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit List Details')).tap()
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Library')).tap()
@@ -77,7 +78,7 @@ describe('Curate lists', () => {
it('Remove avi via the edit curatelist modal', async () => {
await expect(element(by.id('userAvatarImage'))).toExist()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit List Details')).tap()
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Remove')).tap()
@@ -98,6 +99,7 @@ describe('Curate lists', () => {
})
it('Create a new curatelist', async () => {
await element(by.id('e2eGotoLists')).tap()
await element(by.id('newUserListBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editNameInput')).typeText('Good Ppl')
@@ -128,6 +130,7 @@ describe('Curate lists', () => {
})
it('Pins the list', async () => {
await expect(element(by.id('pinBtn'))).toBeVisible()
await element(by.id('pinBtn')).tap()
await element(by.id('e2eGotoHome')).tap()
await element(by.id('homeScreenFeedTabs-Good Ppl')).tap()
@@ -152,15 +155,15 @@ describe('Curate lists', () => {
await expect(element(by.id('user-bob.test'))).toBeVisible()
await element(by.id('user-bob.test-editBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
await element(by.id('toggleBtn-Good Ppl')).tap()
await element(by.id('saveBtn')).tap()
await element(by.id('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
it('Shows the curatelist on my profile', async () => {
await element(by.id('bottomBarProfileBtn')).tap()
await element(by.id('selector')).swipe('left')
await element(by.id('selector-4')).tap()
await element(by.id('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-5')).tap()
await element(by.id('list-Good Ppl')).tap()
})
@@ -173,15 +176,15 @@ describe('Curate lists', () => {
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Add to Lists')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
await element(by.id('toggleBtn-Good Ppl')).tap()
await element(by.id('saveBtn')).tap()
await element(by.id('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Add to Lists')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
await element(by.id('toggleBtn-Good Ppl')).tap()
await element(by.id('saveBtn')).tap()
await element(by.id('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
@@ -192,8 +195,8 @@ describe('Curate lists', () => {
await element(by.id('bottomBarSearchBtn')).tap()
await element(by.id('searchTextInput')).typeText('alice')
await element(by.id('searchAutoCompleteResult-alice.test')).tap()
await element(by.id('selector')).swipe('left')
await element(by.id('selector-3')).tap()
await element(by.id('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-3')).tap()
await element(by.id('list-Good Ppl')).tap()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Report List')).tap()
+23 -4
View File
@@ -1,10 +1,12 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('Home screen', () => {
beforeAll(async () => {
await createServer('?users&follows&posts')
await createServer('?users&follows&posts&feeds')
await openApp({permissions: {notifications: 'YES'}})
})
@@ -13,6 +15,23 @@ describe('Home screen', () => {
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
it('Can go to feeds page using feeds button in tab bar', async () => {
await element(by.id('homeScreenFeedTabs-Feeds ✨')).tap()
await expect(element(by.text('Discover new feeds'))).toBeVisible()
})
it('Feeds button disappears after pinning a feed', async () => {
await element(by.id('bottomBarProfileBtn')).tap()
await element(by.id('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-4')).tap()
await element(by.id('feed-alice-favs')).tap()
await element(by.id('pinBtn')).tap()
await element(by.id('bottomBarHomeBtn')).tap()
await expect(
element(by.id('homeScreenFeedTabs-Feeds ✨')),
).not.toBeVisible()
})
it('Can like posts', async () => {
const carlaPosts = by.id('feedItem-by-carla.test')
await expect(
@@ -65,14 +84,14 @@ describe('Home screen', () => {
it('Can swipe between feeds', async () => {
await element(by.id('homeScreen')).swipe('left', 'fast', 0.75)
await expect(element(by.id('whatshotFeedPage'))).toBeVisible()
await expect(element(by.id('customFeedPage'))).toBeVisible()
await element(by.id('homeScreen')).swipe('right', 'fast', 0.75)
await expect(element(by.id('followingFeedPage'))).toBeVisible()
})
it('Can tap between feeds', async () => {
await element(by.id("homeScreenFeedTabs-What's hot")).tap()
await expect(element(by.id('whatshotFeedPage'))).toBeVisible()
await element(by.id('homeScreenFeedTabs-alice-favs')).tap()
await expect(element(by.id('customFeedPage'))).toBeVisible()
await element(by.id('homeScreenFeedTabs-Following')).tap()
await expect(element(by.id('followingFeedPage'))).toBeVisible()
})
+3 -16
View File
@@ -5,6 +5,8 @@
* with the side drawer.
*/
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('invite-codes', () => {
@@ -16,7 +18,6 @@ describe('invite-codes', () => {
})
it('I can fetch invite codes', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice()
await element(by.id('e2eOpenInviteCodesModal')).tap()
await expect(element(by.id('inviteCodesModal'))).toBeVisible()
@@ -27,6 +28,7 @@ describe('invite-codes', () => {
})
it('I can create a new account with the invite code', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('otherServerBtn')).tap()
@@ -51,19 +53,4 @@ describe('invite-codes', () => {
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
})
it('I get a notification for the new user', async () => {
await element(by.id('e2eSignOut')).tap()
await loginAsAlice()
await waitFor(element(by.id('homeScreen')))
.toBeVisible()
.withTimeout(5000)
await element(by.id('bottomBarNotificationsBtn')).tap()
await expect(element(by.id('invitedUser'))).toBeVisible()
})
it('I can dismiss the new user notification', async () => {
await element(by.id('dismissBtn')).tap()
await expect(element(by.id('invitedUser'))).not.toBeVisible()
})
})
+4
View File
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, login, createServer} from '../util'
describe('Login', () => {
@@ -10,6 +12,8 @@ describe('Login', () => {
})
it('As Alice, I can login', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await expect(element(by.id('signInButton'))).toBeVisible()
await login(service, 'alice', 'hunter2', {
takeScreenshots: true,
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('Mergefeed', () => {
@@ -9,8 +11,12 @@ describe('Mergefeed', () => {
})
it('Login', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await loginAsAlice()
await element(by.id('e2eToggleMergefeed')).tap()
await element(by.id('bottomBarFeedsBtn')).tap()
await element(by.id('feed-alice-favs-toggleSave')).tap()
await element(by.id('e2eGotoHome')).tap()
})
it('Sees the expected mix of posts with default filters', async () => {
+16 -15
View File
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Mod lists', () => {
@@ -11,7 +13,6 @@ describe('Mod lists', () => {
})
it('Login and view my modlists', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice()
await element(by.id('e2eGotoModeration')).tap()
await element(by.id('moderationlistsBtn')).tap()
@@ -31,7 +32,7 @@ describe('Mod lists', () => {
it('Edit display name and description via the edit modlist modal', async () => {
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit List Details')).tap()
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editNameInput')).clearText()
await element(by.id('editNameInput')).typeText('Bad Ppl')
@@ -49,7 +50,7 @@ describe('Mod lists', () => {
it('Remove description via the edit modlist modal', async () => {
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit List Details')).tap()
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editDescriptionInput')).clearText()
await element(by.id('saveBtn')).tap()
@@ -64,7 +65,7 @@ describe('Mod lists', () => {
it('Set avi via the edit modlist modal', async () => {
await expect(element(by.id('userAvatarFallback'))).toExist()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit List Details')).tap()
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Library')).tap()
@@ -81,7 +82,7 @@ describe('Mod lists', () => {
it('Remove avi via the edit modlist modal', async () => {
await expect(element(by.id('userAvatarImage'))).toExist()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit List Details')).tap()
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Remove')).tap()
@@ -131,15 +132,15 @@ describe('Mod lists', () => {
await expect(element(by.id('user-warn-posts.test'))).toBeVisible()
await element(by.id('user-warn-posts.test-editBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
await element(by.id('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
await element(by.id('user-warn-posts.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
it('Shows the modlist on my profile', async () => {
await element(by.id('bottomBarProfileBtn')).tap()
await element(by.id('selector')).swipe('left')
await element(by.id('selector-4')).tap()
await element(by.id('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-5')).tap()
await element(by.id('list-Bad Ppl')).tap()
})
@@ -152,15 +153,15 @@ describe('Mod lists', () => {
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Add to Lists')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
await element(by.id('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
await element(by.id('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Add to Lists')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
await element(by.id('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
await element(by.id('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
@@ -171,8 +172,8 @@ describe('Mod lists', () => {
await element(by.id('bottomBarSearchBtn')).tap()
await element(by.id('searchTextInput')).typeText('alice')
await element(by.id('searchAutoCompleteResult-alice.test')).tap()
await element(by.id('selector')).swipe('left')
await element(by.id('selector-3')).tap()
await element(by.id('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-3')).tap()
await element(by.id('list-Bad Ppl')).tap()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Report List')).tap()
+14 -5
View File
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
describe('Profile screen', () => {
@@ -11,17 +13,16 @@ describe('Profile screen', () => {
})
it('Login and navigate to my profile', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await loginAsAlice()
await element(by.id('bottomBarProfileBtn')).tap()
})
it('Can see feeds', async () => {
await element(by.id('selector')).swipe('left')
await element(by.id('selector-4')).tap()
await element(by.id('profilePager-selector')).swipe('left')
await element(by.id('profilePager-selector-4')).tap()
await expect(element(by.id('feed-alice-favs'))).toBeVisible()
await element(by.id('selector')).swipe('right')
await element(by.id('selector-0')).tap()
await element(by.id('profilePager-selector')).swipe('right')
await element(by.id('profilePager-selector-0')).tap()
})
it('Open and close edit profile modal', async () => {
@@ -135,6 +136,14 @@ describe('Profile screen', () => {
})
it('Can like posts', async () => {
await element(by.id('postsFeed-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
const posts = by.id('feedItem-by-bob.test')
await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
+2
View File
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('Search screen', () => {
+2
View File
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
describe('Self-labeling', () => {
+3 -1
View File
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, loginAsBob, createServer} from '../util'
describe('Thread muting', () => {
@@ -48,7 +50,7 @@ describe('Thread muting', () => {
await loginAsBob()
await element(by.id('bottomBarProfileBtn')).tap()
await element(by.id('selector-1')).tap()
await element(by.id('profilePager-selector-1')).tap()
const bobPosts = by.id('feedItem-by-bob.test')
await element(by.id('replyBtn').withAncestor(bobPosts)).atIndex(0).tap()
await element(by.id('composerTextInput')).typeText('Reply 2')
+8 -6
View File
@@ -1,5 +1,7 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('Thread screen', () => {
@@ -31,15 +33,15 @@ describe('Thread screen', () => {
it('Can like the root post', async () => {
const post = by.id('postThreadItem-by-bob.test')
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
element(by.id('likeCount-expanded').withAncestor(post)).atIndex(0),
).not.toExist()
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
element(by.id('likeCount-expanded').withAncestor(post)).atIndex(0),
).toHaveText('1 like')
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
element(by.id('likeCount-expanded').withAncestor(post)).atIndex(0),
).not.toExist()
})
@@ -61,21 +63,21 @@ describe('Thread screen', () => {
it('Can repost the root post', async () => {
const post = by.id('postThreadItem-by-bob.test')
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
element(by.id('repostCount-expanded').withAncestor(post)).atIndex(0),
).not.toExist()
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
element(by.id('repostCount-expanded').withAncestor(post)).atIndex(0),
).toHaveText('1 repost')
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
element(by.id('repostCount-expanded').withAncestor(post)).atIndex(0),
).not.toExist()
})
@@ -1,3 +1,5 @@
import {it, describe, expect} from '@jest/globals'
import {
linkRequiresWarning,
isPossiblyAUrl,
@@ -6,6 +8,7 @@ import {
describe('linkRequiresWarning', () => {
type Case = [string, string, boolean]
const cases: Case[] = [
['http://example.com', 'http://example.com', false],
['http://example.com', 'example.com', false],
@@ -64,6 +67,10 @@ describe('linkRequiresWarning', () => {
['http://bsky.app/', 'https://google.com', true],
['https://bsky.app/', 'https://google.com', true],
// case insensitive
['https://Example.com', 'example.com', false],
['https://example.com', 'Example.com', false],
// bad uri inputs, default to true
['', '', true],
['example.com', 'example.com', true],
+39 -5
View File
@@ -1,12 +1,41 @@
const pkg = require('./package.json')
module.exports = function () {
const hasSentryToken = !!process.env.SENTRY_AUTH_TOKEN
/**
* App version number. Should be incremented as part of a release cycle.
*/
const VERSION = pkg.version
/**
* iOS build number. Must be incremented for each TestFlight version.
*/
const IOS_BUILD_NUMBER = '7'
/**
* Android build number. Must be incremented for each release.
*/
const ANDROID_VERSION_CODE = 46
/**
* Uses built-in Expo env vars
*
* @see https://docs.expo.dev/build-reference/variables/#built-in-environment-variables
*/
const PLATFORM = process.env.EAS_BUILD_PLATFORM
/**
* Additional granularity for the `dist` field
*/
const DIST_BUILD_NUMBER =
PLATFORM === 'android' ? ANDROID_VERSION_CODE : IOS_BUILD_NUMBER
return {
expo: {
version: VERSION,
name: 'Bluesky',
slug: 'bluesky',
scheme: 'bluesky',
owner: 'blueskysocial',
version: '1.56.0',
runtimeVersion: {
policy: 'appVersion',
},
@@ -19,7 +48,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
ios: {
buildNumber: '3',
buildNumber: IOS_BUILD_NUMBER,
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
config: {
@@ -43,7 +72,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
android: {
versionCode: 46,
versionCode: ANDROID_VERSION_CODE,
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff',
@@ -74,7 +103,7 @@ module.exports = function () {
},
plugins: [
'expo-localization',
hasSentryToken && 'sentry-expo',
Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo',
[
'expo-build-properties',
{
@@ -100,11 +129,16 @@ module.exports = function () {
},
hooks: {
postPublish: [
/*
* @see https://docs.expo.dev/guides/using-sentry/#app-configuration
*/
{
file: 'sentry-expo/upload-sourcemaps',
config: {
organization: 'blueskyweb',
project: 'react-native',
release: VERSION,
dist: `${PLATFORM}.${VERSION}.${DIST_BUILD_NUMBER}`,
},
},
],
+4 -1
View File
@@ -1,5 +1,8 @@
# Testing instructions
Make sure you've copied `.env.example` to `.env.test` and provided any required
values.
### Using Maestro E2E tests
1. Install Maestro by following [these instructions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
2. You can write Maestro tests in `__e2e__/maestro` directory by creating a new `.yaml` file or by modifying an existing one.
@@ -11,4 +14,4 @@
2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/)
3. The simplest way to get started is by running `yarn perf:measure` which will run a live preview of the performance test results. You can [see a demo here](https://github.com/bamlab/flashlight/assets/4534323/4038a342-f145-4c3b-8cde-17949bf52612)
4. The `yarn perf:test:measure` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml` and give the results in `.perf/results.json` which can be viewed by running `yarn:perf:results`
5. You can also run your own tests by running `yarn perf:test <path_to_test>` where `<path_to_test>` is the path to your test file. For example, `yarn perf:test __e2e__/maestro/scroll.yaml` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml`.
5. You can also run your own tests by running `yarn perf:test <path_to_test>` where `<path_to_test>` is the path to your test file. For example, `yarn perf:test __e2e__/maestro/scroll.yaml` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml`.
+13 -22
View File
@@ -4,42 +4,33 @@
"promptToConfigurePushNotifications": false
},
"build": {
"base": {
"node": "18.18.2"
},
"development": {
"extends": "base",
"developmentClient": true,
"distribution": "internal",
"channel": "development",
"ios": {
"simulator": true,
"resourceClass": "m-large"
},
"channel": "development"
},
"development-device": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"resourceClass": "m-large"
},
"channel": "development"
"resourceClass": "large"
}
},
"preview": {
"extends": "base",
"distribution": "internal",
"channel": "preview",
"ios": {
"resourceClass": "m-large"
},
"channel": "preview"
"resourceClass": "large"
}
},
"production": {
"extends": "base",
"ios": {
"resourceClass": "m-large"
"resourceClass": "large"
},
"channel": "production"
},
"dev-android-apk": {
"developmentClient": true,
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
}
}
},
"submit": {
+12 -4
View File
@@ -1,12 +1,20 @@
import 'react-native-gesture-handler' // must be first
import {LogBox} from 'react-native'
LogBox.ignoreLogs(['Require cycle:']) // suppress require-cycle warnings, it's fine
import 'platform/polyfills'
import '#/platform/polyfills'
import {IS_TEST} from '#/env'
import {registerRootComponent} from 'expo'
import {doPolyfill} from '#/lib/api/api-polyfill'
import App from './src/App'
import App from '#/App'
doPolyfill()
if (IS_TEST) {
LogBox.ignoreAllLogs() // suppress all logs in tests
} else {
LogBox.ignoreLogs(['Require cycle:']) // suppress require-cycle warnings, it's fine
}
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
+5 -2
View File
@@ -1,4 +1,7 @@
import 'platform/polyfills'
import '#/platform/polyfills'
import {registerRootComponent} from 'expo'
import App from './src/App'
import {doPolyfill} from '#/lib/api/api-polyfill'
import App from '#/App'
doPolyfill()
registerRootComponent(App)
+3
View File
@@ -2,6 +2,9 @@
import {configure} from '@testing-library/react-native'
import 'react-native-gesture-handler/jestSetup'
// IMPORTANT: this is what's used in the native runtime
import 'react-native-url-polyfill/auto'
configure({asyncUtilTimeout: 20000})
jest.mock('@react-native-async-storage/async-storage', () =>
+6 -2
View File
@@ -59,17 +59,21 @@ export async function createServer(
): Promise<TestPDS> {
const port = await getPort()
const port2 = await getPort(port + 1)
const port3 = await getPort(port2 + 1)
const pdsUrl = `http://localhost:${port}`
const id = ids.next()
const testNet = await TestNetwork.create({
pds: {
port,
publicUrl: pdsUrl,
inviteRequired,
hostname: 'localhost',
dbPostgresSchema: `pds_${id}`,
inviteRequired,
},
bsky: {
dbPostgresSchema: `bsky_${id}`,
port: port3,
publicUrl: 'http://localhost:2584',
},
plc: {port: port2},
})
+10 -7
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.56.0",
"version": "1.57.0",
"private": true,
"scripts": {
"prepare": "is-ci || husky install",
@@ -10,6 +10,7 @@
"ios": "expo run:ios",
"web": "expo start --web",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/",
"build-all": "yarn intl:build && eas build --platform all",
"start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
@@ -20,15 +21,16 @@
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
"typecheck": "tsc --project ./tsconfig.check.json",
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node __e2e__/mock-server.ts",
"e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build": "detox build -c ios.sim.debug",
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all",
"e2e:metro": "NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build": "NODE_ENV=test detox build -c ios.sim.debug",
"e2e:run": "NODE_ENV=test detox test --configuration ios.sim.debug --take-screenshots all",
"perf:test": "NODE_ENV=test maestro test",
"perf:test:run": "NODE_ENV=test maestro test __e2e__/maestro/scroll.yaml",
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
"perf:measure": "NODE_ENV=test flashlight measure",
"build:apk": "eas build -p android --profile dev-android-apk",
"intl:build": "yarn intl:check && yarn intl:compile",
"intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)",
"intl:extract": "lingui extract",
"intl:compile": "lingui compile"
},
@@ -145,7 +147,7 @@
"react-native-pager-view": "6.1.4",
"react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-reanimated": "^3.4.2",
"react-native-reanimated": "^3.6.0",
"react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.6.3",
"react-native-screens": "~3.22.0",
@@ -166,7 +168,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.2.5",
"@atproto/dev-env": "^0.2.16",
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
@@ -204,6 +206,7 @@
"eslint": "^8.19.0",
"eslint-plugin-detox": "^1.0.0",
"eslint-plugin-ft-flow": "^2.0.3",
"eslint-plugin-lingui": "^0.2.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-native-a11y": "^3.3.0",
"html-webpack-plugin": "^5.5.0",
+25 -22
View File
@@ -28,6 +28,8 @@ import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
useSession,
@@ -35,18 +37,13 @@ import {
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
import {messages} from './locale/locales/en/messages'
i18n.load('en', messages)
i18n.activate('en')
enableFreeze(true)
SplashScreen.preventAutoHideAsync()
function InnerApp() {
const colorMode = useColorMode()
const {isInitialLoad} = useSession()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
// init
@@ -73,21 +70,25 @@ function InnerApp() {
*/
return (
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</I18nProvider>
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
)
}
@@ -115,7 +116,9 @@ function App() {
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<InnerApp />
<I18nProvider>
<InnerApp />
</I18nProvider>
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
+25 -22
View File
@@ -16,15 +16,14 @@ 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 LoggedOutViewProvider} from 'state/shell/logged-out'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
useSession,
@@ -36,7 +35,7 @@ import * as persisted from '#/state/persisted'
enableFreeze(true)
function InnerApp() {
const {isInitialLoad} = useSession()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
@@ -44,8 +43,6 @@ function InnerApp() {
useEffect(() => {
initReminders()
analytics.init()
dynamicActivate(defaultLocale) // async import of locale data
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
@@ -61,21 +58,25 @@ function InnerApp() {
*/
return (
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
</I18nProvider>
<ToastContainer />
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
)
}
@@ -103,7 +104,9 @@ function App() {
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<InnerApp />
<I18nProvider>
<InnerApp />
</I18nProvider>
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
+25 -23
View File
@@ -9,7 +9,6 @@ import {
DefaultTheme,
DarkTheme,
} from '@react-navigation/native'
import {createNativeStackNavigator} from '@react-navigation/native-stack'
import {
BottomTabBarProps,
createBottomTabNavigator,
@@ -69,16 +68,18 @@ import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigator<FeedsTabNavigatorParams>()
const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigatorWithAuth<FeedsTabNavigatorParams>()
const NotificationsTab =
createNativeStackNavigator<NotificationsTabNavigatorParams>()
const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>()
const Flat = createNativeStackNavigator<FlatNavigatorParams>()
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
const MyProfileTab =
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
/**
@@ -97,37 +98,37 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="Lists"
component={ListsScreen}
options={{title: title('Lists')}}
options={{title: title('Lists'), requireAuth: true}}
/>
<Stack.Screen
name="Moderation"
getComponent={() => ModerationScreen}
options={{title: title('Moderation')}}
options={{title: title('Moderation'), requireAuth: true}}
/>
<Stack.Screen
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
options={{title: title('Moderation Lists')}}
options={{title: title('Moderation Lists'), requireAuth: true}}
/>
<Stack.Screen
name="ModerationMutedAccounts"
getComponent={() => ModerationMutedAccounts}
options={{title: title('Muted Accounts')}}
options={{title: title('Muted Accounts'), requireAuth: true}}
/>
<Stack.Screen
name="ModerationBlockedAccounts"
getComponent={() => ModerationBlockedAccounts}
options={{title: title('Blocked Accounts')}}
options={{title: title('Blocked Accounts'), requireAuth: true}}
/>
<Stack.Screen
name="Settings"
getComponent={() => SettingsScreen}
options={{title: title('Settings')}}
options={{title: title('Settings'), requireAuth: true}}
/>
<Stack.Screen
name="LanguageSettings"
getComponent={() => LanguageSettingsScreen}
options={{title: title('Language Settings')}}
options={{title: title('Language Settings'), requireAuth: true}}
/>
<Stack.Screen
name="Profile"
@@ -154,7 +155,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="ProfileList"
getComponent={() => ProfileListScreen}
options={{title: title('List')}}
options={{title: title('List'), requireAuth: true}}
/>
<Stack.Screen
name="PostThread"
@@ -184,12 +185,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="Debug"
getComponent={() => DebugScreen}
options={{title: title('Debug')}}
options={{title: title('Debug'), requireAuth: true}}
/>
<Stack.Screen
name="Log"
getComponent={() => LogScreen}
options={{title: title('Log')}}
options={{title: title('Log'), requireAuth: true}}
/>
<Stack.Screen
name="Support"
@@ -219,22 +220,22 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="AppPasswords"
getComponent={() => AppPasswords}
options={{title: title('App Passwords')}}
options={{title: title('App Passwords'), requireAuth: true}}
/>
<Stack.Screen
name="SavedFeeds"
getComponent={() => SavedFeeds}
options={{title: title('Edit My Feeds')}}
options={{title: title('Edit My Feeds'), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesHomeFeed"
getComponent={() => PreferencesHomeFeed}
options={{title: title('Home Feed Preferences')}}
options={{title: title('Home Feed Preferences'), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesThreads"
getComponent={() => PreferencesThreads}
options={{title: title('Threads Preferences')}}
options={{title: title('Threads Preferences'), requireAuth: true}}
/>
</>
)
@@ -339,6 +340,7 @@ function NotificationsTabNavigator() {
<NotificationsTab.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{requireAuth: true}}
/>
{commonScreens(NotificationsTab as typeof HomeTab)}
</NotificationsTab.Navigator>
@@ -357,8 +359,8 @@ function MyProfileTabNavigator() {
contentStyle,
}}>
<MyProfileTab.Screen
name="MyProfile"
// @ts-ignore // TODO: fix this broken type in ProfileScreen
name="MyProfile"
getComponent={() => ProfileScreen}
initialParams={{
name: 'me',
@@ -405,7 +407,7 @@ const FlatNavigator = () => {
<Flat.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{title: title('Notifications')}}
options={{title: title('Notifications'), requireAuth: true}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
+1 -1
View File
@@ -1,4 +1,4 @@
export const IS_TEST = process.env.NODE_ENV === 'test'
export const IS_TEST = process.env.EXPO_PUBLIC_ENV === 'test'
export const IS_DEV = __DEV__
export const IS_PROD = !IS_DEV
export const LOG_DEBUG = process.env.EXPO_PUBLIC_LOG_DEBUG || ''
+19 -15
View File
@@ -16,13 +16,7 @@ export type FeedTunerFn = (
export class FeedViewPostsSlice {
isFlattenedReply = false
constructor(public items: FeedViewPost[] = []) {}
get _reactKey() {
return `slice-${this.items[0].post.uri}-${
this.items[0].reason?.indexedAt || this.items[0].post.indexedAt
}`
}
constructor(public items: FeedViewPost[], public _reactKey: string) {}
get uri() {
if (this.isFlattenedReply) {
@@ -117,28 +111,34 @@ export class FeedViewPostsSlice {
}
export class NoopFeedTuner {
reset() {}
private keyCounter = 0
reset() {
this.keyCounter = 0
}
tune(
feed: FeedViewPost[],
_tunerFns: FeedTunerFn[] = [],
_opts?: {dryRun: boolean; maintainOrder: boolean},
): FeedViewPostsSlice[] {
return feed.map(item => new FeedViewPostsSlice([item]))
return feed.map(
item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
)
}
}
export class FeedTuner {
private keyCounter = 0
seenUris: Set<string> = new Set()
constructor() {}
constructor(public tunerFns: FeedTunerFn[]) {}
reset() {
this.keyCounter = 0
this.seenUris.clear()
}
tune(
feed: FeedViewPost[],
tunerFns: FeedTunerFn[] = [],
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
dryRun: false,
maintainOrder: false,
@@ -147,7 +147,9 @@ export class FeedTuner {
let slices: FeedViewPostsSlice[] = []
if (maintainOrder) {
slices = feed.map(item => new FeedViewPostsSlice([item]))
slices = feed.map(
item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
)
} else {
// arrange the posts into thread slices
for (let i = feed.length - 1; i >= 0; i--) {
@@ -163,12 +165,14 @@ export class FeedTuner {
continue
}
}
slices.unshift(new FeedViewPostsSlice([item]))
slices.unshift(
new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
)
}
}
// run the custom tuners
for (const tunerFn of tunerFns) {
for (const tunerFn of this.tunerFns) {
slices = tunerFn(this, slices.slice())
}
+4 -7
View File
@@ -1,18 +1,15 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class AuthorFeedAPI implements FeedAPI {
constructor(
public agent: BskyAgent,
public params: GetAuthorFeed.QueryParams,
) {}
constructor(public params: GetAuthorFeed.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.getAuthorFeed({
const res = await getAgent().getAuthorFeed({
...this.params,
limit: 1,
})
@@ -26,7 +23,7 @@ export class AuthorFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getAuthorFeed({
const res = await getAgent().getAuthorFeed({
...this.params,
cursor,
limit,
+4 -7
View File
@@ -1,18 +1,15 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class CustomFeedAPI implements FeedAPI {
constructor(
public agent: BskyAgent,
public params: GetCustomFeed.QueryParams,
) {}
constructor(public params: GetCustomFeed.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.app.bsky.feed.getFeed({
const res = await getAgent().app.bsky.feed.getFeed({
...this.params,
limit: 1,
})
@@ -26,7 +23,7 @@ export class CustomFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getFeed({
const res = await getAgent().app.bsky.feed.getFeed({
...this.params,
cursor,
limit,
+5 -4
View File
@@ -1,11 +1,12 @@
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {AppBskyFeedDefs} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class FollowingFeedAPI implements FeedAPI {
constructor(public agent: BskyAgent) {}
constructor() {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.getTimeline({
const res = await getAgent().getTimeline({
limit: 1,
})
return res.data.feed[0]
@@ -18,7 +19,7 @@ export class FollowingFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getTimeline({
const res = await getAgent().getTimeline({
cursor,
limit,
})
+4 -7
View File
@@ -1,18 +1,15 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetActorLikes as GetActorLikes,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class LikesFeedAPI implements FeedAPI {
constructor(
public agent: BskyAgent,
public params: GetActorLikes.QueryParams,
) {}
constructor(public params: GetActorLikes.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.getActorLikes({
const res = await getAgent().getActorLikes({
...this.params,
limit: 1,
})
@@ -26,7 +23,7 @@ export class LikesFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getActorLikes({
const res = await getAgent().getActorLikes({
...this.params,
cursor,
limit,
+4 -7
View File
@@ -1,18 +1,15 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetListFeed as GetListFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class ListFeedAPI implements FeedAPI {
constructor(
public agent: BskyAgent,
public params: GetListFeed.QueryParams,
) {}
constructor(public params: GetListFeed.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.app.bsky.feed.getListFeed({
const res = await getAgent().app.bsky.feed.getListFeed({
...this.params,
limit: 1,
})
@@ -26,7 +23,7 @@ export class ListFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getListFeed({
const res = await getAgent().app.bsky.feed.getListFeed({
...this.params,
cursor,
limit,
+16 -24
View File
@@ -1,4 +1,4 @@
import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
import shuffle from 'lodash.shuffle'
import {timeout} from 'lib/async/timeout'
import {bundleAsync} from 'lib/async/bundle'
@@ -7,6 +7,7 @@ import {FeedTuner} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip'
import {getAgent} from '#/state/session'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -18,16 +19,12 @@ export class MergeFeedAPI implements FeedAPI {
itemCursor = 0
sampleCursor = 0
constructor(
public agent: BskyAgent,
public params: FeedParams,
public feedTuners: FeedTunerFn[],
) {
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) {
this.following = new MergeFeedSource_Following(this.feedTuners)
}
reset() {
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
this.following = new MergeFeedSource_Following(this.feedTuners)
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
this.feedCursor = 0
this.itemCursor = 0
@@ -35,8 +32,7 @@ export class MergeFeedAPI implements FeedAPI {
if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
this.customFeeds = shuffle(
this.params.mergeFeedSources.map(
feedUri =>
new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners),
feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners),
),
)
} else {
@@ -45,7 +41,7 @@ export class MergeFeedAPI implements FeedAPI {
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.getTimeline({
const res = await getAgent().getTimeline({
limit: 1,
})
return res.data.feed[0]
@@ -137,7 +133,7 @@ class MergeFeedSource {
queue: AppBskyFeedDefs.FeedViewPost[] = []
hasMore = true
constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
constructor(public feedTuners: FeedTunerFn[]) {}
get numReady() {
return this.queue.length
@@ -184,7 +180,7 @@ class MergeFeedSource {
}
class MergeFeedSource_Following extends MergeFeedSource {
tuner = new FeedTuner()
tuner = new FeedTuner(this.feedTuners)
reset() {
super.reset()
@@ -199,9 +195,9 @@ class MergeFeedSource_Following extends MergeFeedSource {
cursor: string | undefined,
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.agent.getTimeline({cursor, limit})
const res = await getAgent().getTimeline({cursor, limit})
// run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
const slices = this.tuner.tune(res.data.feed, {
dryRun: false,
maintainOrder: true,
})
@@ -213,20 +209,16 @@ class MergeFeedSource_Following extends MergeFeedSource {
class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date
constructor(
public agent: BskyAgent,
public feedUri: string,
public feedTuners: FeedTunerFn[],
) {
super(agent, feedTuners)
constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) {
super(feedTuners)
this.sourceInfo = {
$type: 'reasonFeedSource',
displayName: feedUri.split('/').pop() || '',
uri: feedUriToHref(feedUri),
}
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
this.agent.app.bsky.feed
.getFeedGenerator({
getAgent()
.app.bsky.feed.getFeedGenerator({
feed: feedUri,
})
.then(
@@ -244,7 +236,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
try {
const res = await this.agent.app.bsky.feed.getFeed({
const res = await getAgent().app.bsky.feed.getFeed({
cursor,
limit,
feed: this.feedUri,
+1
View File
@@ -0,0 +1 @@
export {unstable_batchedUpdates as batchedUpdates} from 'react-native'
+2
View File
@@ -0,0 +1,2 @@
// @ts-ignore
export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
+1
View File
@@ -1 +1,2 @@
export const LOGIN_INCLUDE_DEV_SERVERS = true
export const PWI_ENABLED = false
+2 -2
View File
@@ -116,8 +116,8 @@ export async function DEFAULT_FEEDS(
} else {
// production
return {
pinned: [PROD_DEFAULT_FEED('whats-hot')],
saved: [PROD_DEFAULT_FEED('whats-hot')],
pinned: [],
saved: [],
}
}
}
+31 -5
View File
@@ -1,28 +1,54 @@
import {useCallback} from 'react'
import {useNavigation} from '@react-navigation/native'
import {isWeb} from '#/platform/detection'
import {NavigationProp} from '#/lib/routes/types'
import {useAnalytics} from '#/lib/analytics/analytics'
import {useSessionApi, SessionAccount} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {useCloseAllActiveElements} from '#/state/util'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
export function useAccountSwitcher() {
const {track} = useAnalytics()
const {selectAccount, clearCurrentAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const navigation = useNavigation<NavigationProp>()
const {setShowLoggedOut} = useLoggedOutViewControls()
const onPressSwitchAccount = useCallback(
async (acct: SessionAccount) => {
async (account: SessionAccount) => {
track('Settings:SwitchAccountButtonClicked')
try {
await selectAccount(acct)
closeAllActiveElements()
Toast.show(`Signed in as ${acct.handle}`)
if (account.accessJwt) {
closeAllActiveElements()
navigation.navigate(isWeb ? 'Home' : 'HomeTab')
await selectAccount(account)
setTimeout(() => {
Toast.show(`Signed in as @${account.handle}`)
}, 100)
} else {
closeAllActiveElements()
setShowLoggedOut(true)
Toast.show(
`Please sign in as @${account.handle}`,
'circle-exclamation',
)
}
} catch (e) {
Toast.show('Sorry! We need you to enter your password.')
clearCurrentAccount() // back user out to login
}
},
[track, clearCurrentAccount, selectAccount, closeAllActiveElements],
[
track,
clearCurrentAccount,
selectAccount,
closeAllActiveElements,
navigation,
setShowLoggedOut,
],
)
return {onPressSwitchAccount}
@@ -11,31 +11,31 @@ export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = (
})
return useAnimatedScrollHandler_BUGGY(
{
onBeginDrag(e) {
onBeginDrag(e, ctx) {
if (typeof ref.current !== 'function' && ref.current.onBeginDrag) {
ref.current.onBeginDrag(e)
ref.current.onBeginDrag(e, ctx)
}
},
onEndDrag(e) {
onEndDrag(e, ctx) {
if (typeof ref.current !== 'function' && ref.current.onEndDrag) {
ref.current.onEndDrag(e)
ref.current.onEndDrag(e, ctx)
}
},
onMomentumBegin(e) {
onMomentumBegin(e, ctx) {
if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) {
ref.current.onMomentumBegin(e)
ref.current.onMomentumBegin(e, ctx)
}
},
onMomentumEnd(e) {
onMomentumEnd(e, ctx) {
if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) {
ref.current.onMomentumEnd(e)
ref.current.onMomentumEnd(e, ctx)
}
},
onScroll(e) {
onScroll(e, ctx) {
if (typeof ref.current === 'function') {
ref.current(e)
ref.current(e, ctx)
} else if (ref.current.onScroll) {
ref.current.onScroll(e)
ref.current.onScroll(e, ctx)
}
},
},
+3 -3
View File
@@ -3,6 +3,7 @@ import {useCallback, useEffect} from 'react'
import {AppState} from 'react-native'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {t} from '@lingui/macro'
export function useOTAUpdate() {
const {openModal} = useModalControls()
@@ -11,9 +12,8 @@ export function useOTAUpdate() {
const showUpdatePopup = useCallback(() => {
openModal({
name: 'confirm',
title: 'Update Available',
message:
'A new version of the app is available. Please update to continue using the app.',
title: t`Update Available`,
message: t`A new version of the app is available. Please update to continue using the app.`,
onPressConfirm: async () => {
Updates.reloadAsync().catch(err => {
throw err
+2 -2
View File
@@ -3,8 +3,8 @@ import {isNative} from 'platform/detection'
export function useWebMediaQueries() {
const isDesktop = useMediaQuery({minWidth: 1300})
const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300})
const isMobile = useMediaQuery({maxWidth: 800})
const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1})
const isMobile = useMediaQuery({maxWidth: 800 - 1})
const isTabletOrMobile = isMobile || isTablet
const isTabletOrDesktop = isDesktop || isTablet
if (isNative) {
+20 -1
View File
@@ -1,4 +1,4 @@
import {ModerationCause, ProfileModeration} from '@atproto/api'
import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api'
export interface ModerationCauseDescription {
name: string
@@ -92,6 +92,25 @@ export function getProfileModerationCauses(
}) as ModerationCause[]
}
export function isPostMediaBlurred(
decisions: PostModeration['decisions'],
): boolean {
return decisions.post.blurMedia
}
export function isQuoteBlurred(
decisions: PostModeration['decisions'],
): boolean {
return (
decisions.quote?.blur ||
decisions.quote?.blurMedia ||
decisions.quote?.filter ||
decisions.quotedAccount?.blur ||
decisions.quotedAccount?.filter ||
false
)
}
export function isCauseALabelOnUri(
cause: ModerationCause | undefined,
uri: string,
+3 -2
View File
@@ -5,6 +5,7 @@ import {devicePlatform, isIOS} from 'platform/detection'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
import {truncateAndInvalidate} from '#/state/queries/util'
import {listenSessionLoaded} from '#/state/events'
const SERVICE_DID = (serviceUrl?: string) =>
@@ -83,7 +84,7 @@ export function init(queryClient: QueryClient) {
)
if (event.request.trigger.type === 'push') {
// refresh notifications in the background
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
// handle payload-based deeplinks
let payload
if (isIOS) {
@@ -121,7 +122,7 @@ export function init(queryClient: QueryClient) {
logger.DebugContext.notifications,
)
track('Notificatons:OpenApp')
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
resetToTab('NotificationsTab') // open notifications tab
}
},
+9
View File
@@ -8,6 +8,15 @@ export const queryClient = new QueryClient({
// so we NEVER want to enable this
// -prf
refetchOnWindowFocus: false,
// Structural sharing between responses makes it impossible to rely on
// "first seen" timestamps on objects to determine if they're fresh.
// Disable this optimization so that we can rely on "first seen" timestamps.
structuralSharing: false,
// We don't want to retry queries by default, because in most cases we
// want to fail early and show a response to the user. There are
// exceptions, and those can be made on a per-query basis. For others, we
// should give users controls to retry.
retry: false,
},
},
})
+40 -2
View File
@@ -1,8 +1,46 @@
/**
* Importing these separately from `platform/detection` and `lib/app-info` to
* avoid future conflicts and/or circular deps
*/
import {Platform} from 'react-native'
import app from 'react-native-version-number'
import * as info from 'expo-updates'
import {init} from 'sentry-expo'
/**
* Matches the build profile `channel` props in `eas.json`
*/
const buildChannel = (info.channel || 'development') as
| 'development'
| 'preview'
| 'production'
/**
* Examples:
* - `dev`
* - `1.57.0`
*/
const release = app.appVersion ?? 'dev'
/**
* Examples:
* - `web.dev`
* - `ios.dev`
* - `android.dev`
* - `web.1.57.0`
* - `ios.1.57.0.3`
* - `android.1.57.0.46`
*/
const dist = `${Platform.OS}.${release}${
app.buildVersion ? `.${app.buildVersion}` : ''
}`
init({
dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode.
debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
environment: __DEV__ ? 'development' : 'production', // Set the environment
enableInExpoDevelopment: true,
environment: buildChannel,
dist,
release,
})
+20 -5
View File
@@ -168,8 +168,15 @@ export function getYoutubeVideoId(link: string): string | undefined {
return videoId
}
/**
* Checks if the label in the post text matches the host of the link facet.
*
* Hosts are case-insensitive, so should be lowercase for comparison.
* @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
*/
export function linkRequiresWarning(uri: string, label: string) {
const labelDomain = labelToDomain(label)
let urip
try {
urip = new URL(uri)
@@ -177,7 +184,9 @@ export function linkRequiresWarning(uri: string, label: string) {
return true
}
if (urip.hostname === 'bsky.app') {
const host = urip.hostname.toLowerCase()
if (host === 'bsky.app') {
// if this is a link to internal content,
// warn if it represents itself as a URL to another app
if (
@@ -194,20 +203,26 @@ export function linkRequiresWarning(uri: string, label: string) {
if (!labelDomain) {
return true
}
return labelDomain !== urip.hostname
return labelDomain !== host
}
}
function labelToDomain(label: string): string | undefined {
/**
* Returns a lowercase domain hostname if the label is a valid URL.
*
* Hosts are case-insensitive, so should be lowercase for comparison.
* @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
*/
export function labelToDomain(label: string): string | undefined {
// any spaces just immediately consider the label a non-url
if (/\s/.test(label)) {
return undefined
}
try {
return new URL(label).hostname
return new URL(label).hostname.toLowerCase()
} catch {}
try {
return new URL('https://' + label).hostname
return new URL('https://' + label).hostname.toLowerCase()
} catch {}
return undefined
}
+21 -3
View File
@@ -1,4 +1,8 @@
import {useLanguagePrefs} from '#/state/preferences'
import {i18n} from '@lingui/core'
import {useEffect} from 'react'
import {messages as messagesEn} from './locales/en/messages'
import {messages as messagesHi} from './locales/hi/messages'
export const locales = {
en: 'English',
@@ -14,7 +18,21 @@ export const defaultLocale = 'en'
* @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)
if (locale === 'en') {
i18n.loadAndActivate({locale, messages: messagesEn})
return
} else if (locale === 'hi') {
i18n.loadAndActivate({locale, messages: messagesHi})
return
} else {
i18n.loadAndActivate({locale, messages: messagesEn})
return
}
}
export async function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs()
useEffect(() => {
dynamicActivate(appLanguage)
}, [appLanguage])
}
+29
View File
@@ -0,0 +1,29 @@
import {useLanguagePrefs} from '#/state/preferences'
import {i18n} from '@lingui/core'
import {useEffect} from 'react'
export const locales = {
en: 'English',
cs: 'Česky',
fr: 'Français',
hi: 'हिंदी',
es: 'Español',
}
export const defaultLocale = 'en'
/**
* We do a dynamic import of just the catalog that we need
* @param locale any locale string
*/
export async function dynamicActivate(locale: string) {
const {messages} = await import(`./locales/${locale}/messages`)
i18n.load(locale, messages)
i18n.activate(locale)
}
export async function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs()
useEffect(() => {
dynamicActivate(appLanguage)
}, [appLanguage])
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import {I18nProvider as DefaultI18nProvider} from '@lingui/react'
import {i18n} from '@lingui/core'
import {useLocaleLanguage} from './i18n'
export default function I18nProvider({children}: {children: React.ReactNode}) {
useLocaleLanguage()
return <DefaultI18nProvider i18n={i18n}>{children}</DefaultI18nProvider>
}
+10
View File
@@ -4,6 +4,16 @@ interface Language {
name: string
}
interface AppLanguage {
code2: string
name: string
}
export const APP_LANGUAGES: AppLanguage[] = [
{code2: 'en', name: 'English'},
{code2: 'hi', name: 'हिंदी'},
]
export const LANGUAGES: Language[] = [
{code3: 'aar', code2: 'aa', name: 'Afar'},
{code3: 'abk', code2: 'ab', name: 'Abkhazian'},
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
+20
View File
@@ -222,6 +222,26 @@ describe('general functionality', () => {
})
})
test('sentryTransport serializes errors', () => {
const message = 'message'
const timestamp = Date.now()
const sentryTimestamp = timestamp / 1000
sentryTransport(
LogLevel.Debug,
message,
{error: new Error('foo')},
timestamp,
)
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
message,
data: {error: 'Error: foo'},
type: 'default',
level: LogLevel.Debug,
timestamp: sentryTimestamp,
})
})
test('add/remove transport', () => {
const timestamp = Date.now()
const logger = new Logger({enabled: true})
+20 -10
View File
@@ -90,6 +90,16 @@ const enabledLogLevels: {
[LogLevel.Error]: [LogLevel.Error],
}
export function prepareMetadata(metadata: Metadata): Metadata {
return Object.keys(metadata).reduce((acc, key) => {
let value = metadata[key]
if (value instanceof Error) {
value = value.toString()
}
return {...acc, [key]: value}
}, {})
}
/**
* Used in dev mode to nicely log to the console
*/
@@ -100,7 +110,8 @@ export const consoleTransport: Transport = (
timestamp,
) => {
const extra = Object.keys(metadata).length
? ' ' + JSON.stringify(metadata, null, ' ')
? // don't prepareMetadata here, in dev we want the stack trace
' ' + JSON.stringify(metadata, null, ' ')
: ''
const log = {
[LogLevel.Debug]: console.debug,
@@ -119,6 +130,8 @@ export const sentryTransport: Transport = (
{type, tags, ...metadata},
timestamp,
) => {
const meta = prepareMetadata(metadata)
/**
* If a string, report a breadcrumb
*/
@@ -135,7 +148,7 @@ export const sentryTransport: Transport = (
Sentry.addBreadcrumb({
message,
data: metadata,
data: meta,
type: type || 'default',
level: severity,
timestamp: timestamp / 1000, // Sentry expects seconds
@@ -155,7 +168,7 @@ export const sentryTransport: Transport = (
Sentry.captureMessage(message, {
level: messageLevel,
tags,
extra: metadata,
extra: meta,
})
}
} else {
@@ -164,7 +177,7 @@ export const sentryTransport: Transport = (
*/
Sentry.captureException(message, {
tags,
extra: metadata,
extra: meta,
})
}
}
@@ -275,16 +288,13 @@ export class Logger {
*/
export const logger = new Logger()
/**
* Report to console in dev, Sentry in prod, nothing in test.
*/
if (env.IS_DEV && !env.IS_TEST) {
logger.addTransport(consoleTransport)
/**
* Uncomment this to test Sentry in dev
* Comment this out to disable Sentry transport in dev
*/
// logger.addTransport(sentryTransport);
logger.addTransport(sentryTransport)
} else if (env.IS_PROD) {
// logger.addTransport(sentryTransport)
logger.addTransport(sentryTransport)
}
+61 -61
View File
@@ -1,11 +1,14 @@
import {useEffect, useState, useCallback, useRef} from 'react'
import {useEffect, useState, useMemo} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyFeedDefs} from '@atproto/api'
import {Shadow} from './types'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {Shadow, castAsShadow} from './types'
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '../queries/notifications/feed'
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '../queries/post-feed'
import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '../queries/post-thread'
import {queryClient} from 'lib/react-query'
export type {Shadow} from './types'
const emitter = new EventEmitter()
export interface PostShadow {
likeUri: string | undefined
likeCount: number | undefined
@@ -16,86 +19,83 @@ export interface PostShadow {
export const POST_TOMBSTONE = Symbol('PostTombstone')
interface CacheEntry {
ts: number
value: PostShadow
}
const emitter = new EventEmitter()
const shadows: WeakMap<
AppBskyFeedDefs.PostView,
Partial<PostShadow>
> = new WeakMap()
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 [shadow, setShadow] = useState(() => shadows.get(post))
const [prevPost, setPrevPost] = useState(post)
if (post !== prevPost) {
setPrevPost(post)
setShadow(shadows.get(post))
}
const onUpdate = useCallback(
(value: Partial<PostShadow>) => {
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
},
[setState],
)
// react to shadow updates
useEffect(() => {
function onUpdate() {
setShadow(shadows.get(post))
}
emitter.addListener(post.uri, onUpdate)
return () => {
emitter.removeListener(post.uri, onUpdate)
}
}, [post.uri, onUpdate])
}, [post, setShadow])
// 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)})
return useMemo(() => {
if (shadow) {
return mergeShadow(post, shadow)
} else {
return castAsShadow(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,
}
}, [post, shadow])
}
function mergeShadow(
post: AppBskyFeedDefs.PostView,
shadow: PostShadow,
shadow: Partial<PostShadow>,
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
if (shadow.isDeleted) {
return POST_TOMBSTONE
}
return {
return castAsShadow({
...post,
likeCount: shadow.likeCount,
repostCount: shadow.repostCount,
likeCount: 'likeCount' in shadow ? shadow.likeCount : post.likeCount,
repostCount:
'repostCount' in shadow ? shadow.repostCount : post.repostCount,
viewer: {
...(post.viewer || {}),
like: shadow.likeUri,
repost: shadow.repostUri,
like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost,
},
isShadowed: true,
})
}
export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
const cachedPosts = findPostsInCache(uri)
for (let post of cachedPosts) {
shadows.set(post, {...shadows.get(post), ...value})
}
batchedUpdates(() => {
emitter.emit(uri)
})
}
function* findPostsInCache(
uri: string,
): Generator<AppBskyFeedDefs.PostView, void> {
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
yield post
}
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
yield post
}
for (let node of findAllPostsInThreadQueryData(queryClient, uri)) {
if (node.type === 'post') {
yield node.post
}
}
}
+61 -59
View File
@@ -1,99 +1,101 @@
import {useEffect, useState, useCallback, useRef} from 'react'
import {useEffect, useState, useMemo} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyActorDefs} from '@atproto/api'
import {Shadow} from './types'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members'
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts'
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts'
import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '../queries/post-liked-by'
import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '../queries/post-reposted-by'
import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '../queries/profile'
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '../queries/profile-followers'
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '../queries/profile-follows'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '../queries/suggested-follows'
import {Shadow, castAsShadow} from './types'
import {queryClient} from 'lib/react-query'
export type {Shadow} from './types'
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 shadows: WeakMap<ProfileView, Partial<ProfileShadow>> = new WeakMap()
const emitter = new EventEmitter()
const onUpdate = useCallback(
(value: Partial<ProfileShadow>) => {
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
},
[setState],
)
export function useProfileShadow(profile: ProfileView): Shadow<ProfileView> {
const [shadow, setShadow] = useState(() => shadows.get(profile))
const [prevPost, setPrevPost] = useState(profile)
if (profile !== prevPost) {
setPrevPost(profile)
setShadow(shadows.get(profile))
}
// react to shadow updates
useEffect(() => {
function onUpdate() {
setShadow(shadows.get(profile))
}
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}
return useMemo(() => {
if (shadow) {
return mergeShadow(profile, shadow)
} else {
return castAsShadow(profile)
}
}, [profile, shadow])
}
export function updateProfileShadow(
uri: string,
did: 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,
const cachedProfiles = findProfilesInCache(did)
for (let post of cachedProfiles) {
shadows.set(post, {...shadows.get(post), ...value})
}
batchedUpdates(() => {
emitter.emit(did, value)
})
}
function mergeShadow(
profile: ProfileView,
shadow: ProfileShadow,
shadow: Partial<ProfileShadow>,
): Shadow<ProfileView> {
return {
return castAsShadow({
...profile,
viewer: {
...(profile.viewer || {}),
following: shadow.followingUri,
muted: shadow.muted,
blocking: shadow.blockingUri,
following:
'followingUri' in shadow
? shadow.followingUri
: profile.viewer?.following,
muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted,
blocking:
'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking,
},
isShadowed: true,
}
})
}
function* findProfilesInCache(did: string): Generator<ProfileView, void> {
yield* findAllProfilesInListMembersQueryData(queryClient, did)
yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did)
yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did)
yield* findAllProfilesInPostLikedByQueryData(queryClient, did)
yield* findAllProfilesInPostRepostedByQueryData(queryClient, did)
yield* findAllProfilesInProfileQueryData(queryClient, did)
yield* findAllProfilesInProfileFollowersQueryData(queryClient, did)
yield* findAllProfilesInProfileFollowsQueryData(queryClient, did)
yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
}
+7 -1
View File
@@ -1 +1,7 @@
export type Shadow<T> = T & {isShadowed: true}
// This isn't a real property, but it prevents T being compatible with Shadow<T>.
declare const shadowTag: unique symbol
export type Shadow<T> = T & {[shadowTag]: true}
export function castAsShadow<T>(value: T): Shadow<T> {
return value as any as Shadow<T>
}
+8
View File
@@ -36,3 +36,11 @@ export function listenSessionDropped(fn: () => void): UnlistenFn {
emitter.on('session-dropped', fn)
return () => emitter.off('session-dropped', fn)
}
export function emitPostCreated() {
emitter.emit('post-created')
}
export function listenPostCreated(fn: () => void): UnlistenFn {
emitter.on('post-created', fn)
return () => emitter.off('post-created', fn)
}
+1
View File
@@ -61,6 +61,7 @@ export interface CreateOrEditListModal {
export interface UserAddRemoveListsModal {
name: 'user-add-remove-lists'
subject: string
handle: string
displayName: string
onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
+3 -2
View File
@@ -19,7 +19,7 @@ const _emitter = new EventEmitter()
* the Provider.
*/
export async function init() {
logger.debug('persisted state: initializing')
logger.info('persisted state: initializing')
broadcast.onmessage = onBroadcastMessage
@@ -28,11 +28,12 @@ export async function init() {
const stored = await store.read() // check for new store
if (!stored) await store.write(defaults) // opt: init new store
_state = stored || defaults // return new store
logger.log('persisted state: initialized')
} catch (e) {
logger.error('persisted state: failed to load root state from storage', {
error: e,
})
// AsyncStorage failured, but we can still continue in memory
// AsyncStorage failure, but we can still continue in memory
return defaults
}
}
+48 -4
View File
@@ -94,6 +94,8 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
postLanguageHistory:
legacy.preferences?.postLanguageHistory ||
defaults.languagePrefs.postLanguageHistory,
appLanguage:
legacy.preferences?.postLanguage || defaults.languagePrefs.appLanguage,
},
requireAltTextEnabled:
legacy.preferences?.requireAltTextEnabled ||
@@ -114,20 +116,52 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
* local storage AND old storage exists.
*/
export async function migrate() {
logger.debug('persisted state: migrate')
logger.info('persisted state: migrate')
try {
const rawLegacyData = await AsyncStorage.getItem(
DEPRECATED_ROOT_STATE_STORAGE_KEY,
)
const alreadyMigrated = Boolean(await read())
const newData = await read()
const alreadyMigrated = Boolean(newData)
try {
if (rawLegacyData) {
const legacy = JSON.parse(rawLegacyData) as Partial<LegacySchema>
logger.info(`persisted state: debug legacy data`, {
hasExistingLoggedInAccount: Boolean(legacy?.session?.data),
numberOfExistingAccounts: legacy?.session?.accounts?.length,
foundExistingCurrentAccount: Boolean(
legacy.session?.accounts?.find(
a => a.did === legacy.session?.data?.did,
),
),
})
logger.info(`persisted state: debug new data`, {
hasExistingLoggedInAccount: Boolean(newData?.session?.currentAccount),
numberOfExistingAccounts: newData?.session?.accounts?.length,
existingAccountMatchesLegacy: Boolean(
newData?.session?.currentAccount?.did ===
legacy?.session?.data?.did,
),
})
} else {
logger.info(`persisted state: no legacy to debug, fresh install`)
}
} catch (e) {
logger.error(`persisted state: legacy debugging failed`, {error: e})
}
if (!alreadyMigrated && rawLegacyData) {
logger.debug('persisted state: migrating legacy storage')
logger.info('persisted state: migrating legacy storage')
const legacyData = JSON.parse(rawLegacyData)
const newData = transform(legacyData)
await write(newData)
logger.debug('persisted state: migrated legacy storage')
// track successful migrations
logger.log('persisted state: migrated legacy storage')
} else {
// track successful migrations
logger.log('persisted state: no migration needed')
}
} catch (e) {
logger.error('persisted state: error migrating legacy storage', {
@@ -135,3 +169,13 @@ export async function migrate() {
})
}
}
export async function clearLegacyStorage() {
try {
await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY)
} catch (e: any) {
logger.error(`persisted legacy store: failed to clear`, {
error: e.toString(),
})
}
}
+2
View File
@@ -30,6 +30,7 @@ export const schema = z.object({
contentLanguages: z.array(z.string()), // should move to server
postLanguage: z.string(), // should move to server
postLanguageHistory: z.array(z.string()),
appLanguage: z.string(),
}),
requireAltTextEnabled: z.boolean(), // should move to server
mutedThreads: z.array(z.string()), // should move to server
@@ -58,6 +59,7 @@ export const defaults: Schema = {
postLanguageHistory: (deviceLocales || [])
.concat(['en', 'ja', 'pt', 'de'])
.slice(0, 6),
appLanguage: deviceLocales[0] || 'en',
},
requireAltTextEnabled: false,
mutedThreads: [],
+9
View File
@@ -1,6 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {Schema, schema} from '#/state/persisted/schema'
import {logger} from '#/logger'
const BSKY_STORAGE = 'BSKY_STORAGE'
@@ -16,3 +17,11 @@ export async function read(): Promise<Schema | undefined> {
return objData
}
}
export async function clear() {
try {
await AsyncStorage.removeItem(BSKY_STORAGE)
} catch (e: any) {
logger.error(`persisted store: failed to clear`, {error: e.toString()})
}
}
+13 -9
View File
@@ -2,9 +2,13 @@ import {useMemo} from 'react'
import {FeedTuner} from '#/lib/api/feed-manip'
import {FeedDescriptor} from '../queries/post-feed'
import {useLanguagePrefs} from './languages'
import {usePreferencesQuery} from '../queries/preferences'
import {useSession} from '../session'
export function useFeedTuners(feedDesc: FeedDescriptor) {
const langPrefs = useLanguagePrefs()
const {data: preferences} = usePreferencesQuery()
const {currentAccount} = useSession()
return useMemo(() => {
if (feedDesc.startsWith('feedgen')) {
@@ -19,30 +23,30 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
if (feedDesc === 'home' || feedDesc === 'following') {
const feedTuners = []
if (false /*TODOthis.homeFeed.hideReposts*/) {
if (preferences?.feedViewPrefs.hideReposts) {
feedTuners.push(FeedTuner.removeReposts)
} else {
feedTuners.push(FeedTuner.dedupReposts)
}
if (true /*TODOthis.homeFeed.hideReplies*/) {
if (preferences?.feedViewPrefs.hideReplies) {
feedTuners.push(FeedTuner.removeReplies)
} /* TODO else {
} else {
feedTuners.push(
FeedTuner.thresholdRepliesOnly({
userDid: this.rootStore.session.data?.did || '',
minLikes: this.homeFeed.hideRepliesByLikeCount,
followedOnly: !!this.homeFeed.hideRepliesByUnfollowed,
userDid: currentAccount?.did || '',
minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0,
followedOnly: !!preferences?.feedViewPrefs.hideRepliesByUnfollowed,
}),
)
}*/
}
if (false /*TODOthis.homeFeed.hideQuotePosts*/) {
if (preferences?.feedViewPrefs.hideQuotePosts) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
return feedTuners
}
return []
}, [feedDesc, langPrefs])
}, [feedDesc, currentAccount, preferences, langPrefs])
}
+5
View File
@@ -11,6 +11,7 @@ type ApiContext = {
toggleContentLanguage: (code2: string) => void
togglePostLanguage: (code2: string) => void
savePostLanguageToHistory: () => void
setAppLanguage: (code2: string) => void
}
const stateContext = React.createContext<StateContext>(
@@ -22,6 +23,7 @@ const apiContext = React.createContext<ApiContext>({
toggleContentLanguage: (_: string) => {},
togglePostLanguage: (_: string) => {},
savePostLanguageToHistory: () => {},
setAppLanguage: (_: string) => {},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
@@ -104,6 +106,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
.slice(0, 6),
}))
},
setAppLanguage(code2: string) {
setStateWrapped(s => ({...s, appLanguage: code2}))
},
}),
[state, setStateWrapped],
)
+43 -10
View File
@@ -160,6 +160,39 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) {
})
}
export const isFeedPublicQueryKey = ({uri}: {uri: string}) => [
'isFeedPublic',
uri,
]
export function useIsFeedPublicQuery({uri}: {uri: string}) {
return useQuery({
queryKey: isFeedPublicQueryKey({uri}),
queryFn: async ({queryKey}) => {
const [, uri] = queryKey
try {
const res = await getAgent().app.bsky.feed.getFeed({
feed: uri,
limit: 1,
})
return Boolean(res.data.feed)
} catch (e: any) {
/**
* This should be an `XRPCError`, but I can't safely import from
* `@atproto/xrpc` due to a depdency on node's `crypto` module.
*
* @see https://github.com/bluesky-social/atproto/blob/c17971a2d8e424cc7f10c071d97c07c08aa319cf/packages/xrpc/src/client.ts#L126
*/
if (e?.status === 401) {
return false
}
return true
}
},
})
}
export const useGetPopularFeedsQueryKey = ['getPopularFeeds']
export function useGetPopularFeedsQuery() {
@@ -214,13 +247,19 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
likeUri: '',
}
export function usePinnedFeedsInfos(): FeedSourceInfo[] {
export function usePinnedFeedsInfos(): {
feeds: FeedSourceInfo[]
hasPinnedCustom: boolean
} {
const queryClient = useQueryClient()
const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([
FOLLOWING_FEED_STUB,
])
const {data: preferences} = usePreferencesQuery()
const pinnedFeedsKey = JSON.stringify(preferences?.feeds?.pinned)
const hasPinnedCustom = React.useMemo<boolean>(() => {
return tabs.some(tab => tab !== FOLLOWING_FEED_STUB)
}, [tabs])
React.useEffect(() => {
if (!preferences?.feeds?.pinned) return
@@ -267,13 +306,7 @@ export function usePinnedFeedsInfos(): FeedSourceInfo[] {
}
fetchFeedInfo()
}, [
queryClient,
setTabs,
preferences?.feeds?.pinned,
// ensure we react to re-ordering
pinnedFeedsKey,
])
}, [queryClient, setTabs, preferences?.feeds?.pinned])
return tabs
return {feeds: tabs, hasPinnedCustom}
}
+20 -1
View File
@@ -3,6 +3,7 @@ import {useQuery} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
import {cleanError} from '#/lib/strings/errors'
function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean {
return invite.available - invite.uses.length > 0 && !invite.disabled
@@ -17,7 +18,24 @@ export function useInviteCodesQuery() {
staleTime: STALE.HOURS.ONE,
queryKey: ['inviteCodes'],
queryFn: async () => {
const res = await getAgent().com.atproto.server.getAccountInviteCodes({})
const res = await getAgent()
.com.atproto.server.getAccountInviteCodes({})
.catch(e => {
if (cleanError(e) === 'Bad token scope') {
return null
} else {
throw e
}
})
if (res === null) {
return {
disabled: true,
all: [],
available: [],
used: [],
}
}
if (!res.data?.codes) {
throw new Error(`useInviteCodesQuery: no codes returned`)
@@ -27,6 +45,7 @@ export function useInviteCodesQuery() {
const used = res.data.codes.filter(code => !isInviteAvailable(code))
return {
disabled: false,
all: [...available, ...used],
available,
used,
+38 -2
View File
@@ -1,5 +1,10 @@
import {AppBskyGraphGetList} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {AppBskyActorDefs, AppBskyGraphGetList} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
@@ -31,3 +36,34 @@ export function useListMembersQuery(uri: string) {
getNextPageParam: lastPage => lastPage.cursor,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetList.OutputSchema>
>({
queryKey: ['list-members'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) {
continue
}
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
if (page.list.creator.did === did) {
yield page.list.creator
}
for (const item of page.items) {
if (item.subject.did === did) {
yield item.subject
}
}
}
}
}
}
+13 -27
View File
@@ -3,7 +3,6 @@ import {
AppBskyGraphGetList,
AppBskyGraphList,
AppBskyGraphDefs,
BskyAgent,
} from '@atproto/api'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
@@ -75,13 +74,9 @@ export function useListCreateMutation() {
)
// wait for the appview to update
await whenAppViewReady(
getAgent(),
res.uri,
(v: AppBskyGraphGetList.Response) => {
return typeof v?.data?.list.uri === 'string'
},
)
await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => {
return typeof v?.data?.list.uri === 'string'
})
return res
},
onSuccess() {
@@ -142,16 +137,12 @@ export function useListMetadataMutation() {
).data
// wait for the appview to update
await whenAppViewReady(
getAgent(),
res.uri,
(v: AppBskyGraphGetList.Response) => {
const list = v.data.list
return (
list.name === record.name && list.description === record.description
)
},
)
await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => {
const list = v.data.list
return (
list.name === record.name && list.description === record.description
)
})
return res
},
onSuccess(data, variables) {
@@ -216,13 +207,9 @@ export function useListDeleteMutation() {
}
// wait for the appview to update
await whenAppViewReady(
getAgent(),
uri,
(v: AppBskyGraphGetList.Response) => {
return !v?.success
},
)
await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => {
return !v?.success
})
},
onSuccess() {
invalidateMyLists(queryClient)
@@ -271,7 +258,6 @@ export function useListBlockMutation() {
}
async function whenAppViewReady(
agent: BskyAgent,
uri: string,
fn: (res: AppBskyGraphGetList.Response) => boolean,
) {
@@ -280,7 +266,7 @@ async function whenAppViewReady(
1e3, // 1s delay between tries
fn,
() =>
agent.app.bsky.graph.getList({
getAgent().app.bsky.graph.getList({
list: uri,
limit: 1,
}),
+30 -4
View File
@@ -1,8 +1,12 @@
import {AppBskyGraphGetBlocks} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {AppBskyActorDefs, AppBskyGraphGetBlocks} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
export const RQKEY = () => ['my-blocked-accounts']
type RQPageParam = string | undefined
@@ -15,7 +19,6 @@ export function useMyBlockedAccountsQuery() {
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().app.bsky.graph.getBlocks({
@@ -28,3 +31,26 @@ export function useMyBlockedAccountsQuery() {
getNextPageParam: lastPage => lastPage.cursor,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetBlocks.OutputSchema>
>({
queryKey: ['my-blocked-accounts'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const block of page.blocks) {
if (block.did === did) {
yield block
}
}
}
}
}
+30 -4
View File
@@ -1,8 +1,12 @@
import {AppBskyGraphGetMutes} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {AppBskyActorDefs, AppBskyGraphGetMutes} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
export const RQKEY = () => ['my-muted-accounts']
type RQPageParam = string | undefined
@@ -15,7 +19,6 @@ export function useMyMutedAccountsQuery() {
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().app.bsky.graph.getMutes({
@@ -28,3 +31,26 @@ export function useMyMutedAccountsQuery() {
getNextPageParam: lastPage => lastPage.cursor,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetMutes.OutputSchema>
>({
queryKey: ['my-muted-accounts'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const mute of page.mutes) {
if (mute.did === did) {
yield mute
}
}
}
}
}
+87 -173
View File
@@ -1,55 +1,51 @@
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedLike,
AppBskyNotificationListNotifications,
BskyAgent,
} from '@atproto/api'
import chunk from 'lodash.chunk'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '../../session'
import {useModerationOpts} from '../preferences'
import {shouldFilterNotif} from './util'
import {useMutedThreads} from '#/state/muted-threads'
/**
* NOTE
* The ./unread.ts API:
*
* - Provides a `checkUnread()` function to sync with the server,
* - Periodically calls `checkUnread()`, and
* - Caches the first page of notifications.
*
* IMPORTANT: This query uses ./unread.ts's cache as its first page,
* IMPORTANT: which means the cache-freshness of this query is driven by the unread API.
*
* Follow these rules:
*
* 1. Call `checkUnread()` if you want to fetch latest in the background.
* 2. Call `checkUnread({invalidate: true})` if you want latest to sync into this query's results immediately.
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
*/
import {AppBskyFeedDefs} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryKey,
useQueryClient,
QueryClient,
} from '@tanstack/react-query'
import {useModerationOpts} from '../preferences'
import {useUnreadNotificationsApi} from './unread'
import {fetchPage} from './util'
import {FeedPage} from './types'
import {useMutedThreads} from '#/state/muted-threads'
import {STALE} from '..'
export type {NotificationType, FeedNotification, FeedPage} from './types'
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
const PAGE_SIZE = 30
const MS_1HR = 1e3 * 60 * 60
const MS_2DAY = MS_1HR * 48
type RQPageParam = string | undefined
type NotificationType =
| 'post-like'
| 'feedgen-like'
| 'repost'
| 'mention'
| 'reply'
| 'quote'
| 'follow'
| 'unknown'
export function RQKEY() {
return ['notification-feed']
}
export interface FeedNotification {
_reactKey: string
type: NotificationType
notification: AppBskyNotificationListNotifications.Notification
additional?: AppBskyNotificationListNotifications.Notification[]
subjectUri?: string
subject?: AppBskyFeedDefs.PostView
}
export interface FeedPage {
cursor: string | undefined
items: FeedNotification[]
}
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false
return useInfiniteQuery<
@@ -59,39 +55,30 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
QueryKey,
RQPageParam
>({
staleTime: STALE.INFINITY,
queryKey: RQKEY(),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().listNotifications({
limit: PAGE_SIZE,
cursor: pageParam,
})
// filter out notifs by mod rules
const notifs = res.data.notifications.filter(
notif => !shouldFilterNotif(notif, moderationOpts),
)
// group notifications which are essentially similar (follows, likes on a post)
let notifsGrouped = groupNotifications(notifs)
// we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts
const subjects = await fetchSubjects(getAgent(), notifsGrouped)
for (const notif of notifsGrouped) {
if (notif.subjectUri) {
notif.subject = subjects.get(notif.subjectUri)
}
let page
if (!pageParam) {
// for the first page, we check the cached page held by the unread-checker first
page = unreads.getCachedUnreadPage()
}
if (!page) {
page = await fetchPage({
limit: PAGE_SIZE,
cursor: pageParam,
queryClient,
moderationOpts,
threadMutes,
})
}
// apply thread muting
notifsGrouped = notifsGrouped.filter(
notif => !isThreadMuted(notif, threadMutes),
)
return {
cursor: res.data.cursor,
items: notifsGrouped,
// if the first page has an unread, mark all read
if (!pageParam && page.items[0] && !page.items[0].notification.isRead) {
unreads.markAllRead()
}
return page
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
@@ -99,113 +86,40 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
})
}
function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[],
): FeedNotification[] {
const groupedNotifs: FeedNotification[] = []
for (const notif of notifs) {
const ts = +new Date(notif.indexedAt)
let grouped = false
if (GROUPABLE_REASONS.includes(notif.reason)) {
for (const groupedNotif of groupedNotifs) {
const ts2 = +new Date(groupedNotif.notification.indexedAt)
if (
Math.abs(ts2 - ts) < MS_2DAY &&
notif.reason === groupedNotif.notification.reason &&
notif.reasonSubject === groupedNotif.notification.reasonSubject &&
notif.author.did !== groupedNotif.notification.author.did
) {
groupedNotif.additional = groupedNotif.additional || []
groupedNotif.additional.push(notif)
grouped = true
break
/**
* This helper is used by the post-thread placeholder function to
* find a post in the query-data cache
*/
export function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): AppBskyFeedDefs.PostView | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
}
}
export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<AppBskyFeedDefs.PostView, void> {
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
queryKey: ['notification-feed'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const item of page.items) {
if (item.subject?.uri === uri) {
yield item.subject
}
}
}
if (!grouped) {
const type = toKnownType(notif)
groupedNotifs.push({
_reactKey: `notif-${notif.uri}`,
type,
notification: notif,
subjectUri: getSubjectUri(type, notif),
})
}
}
return groupedNotifs
}
async function fetchSubjects(
agent: BskyAgent,
groupedNotifs: FeedNotification[],
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>()
for (const notif of groupedNotifs) {
if (notif.subjectUri) {
uris.add(notif.subjectUri)
}
}
const uriChunks = chunk(Array.from(uris), 25)
const postsChunks = await Promise.all(
uriChunks.map(uris =>
agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts),
),
)
const map = new Map<string, AppBskyFeedDefs.PostView>()
for (const post of postsChunks.flat()) {
if (
AppBskyFeedPost.isRecord(post.record) &&
AppBskyFeedPost.validateRecord(post.record).success
) {
map.set(post.uri, post)
}
}
return map
}
function toKnownType(
notif: AppBskyNotificationListNotifications.Notification,
): NotificationType {
if (notif.reason === 'like') {
if (notif.reasonSubject?.includes('feed.generator')) {
return 'feedgen-like'
}
return 'post-like'
}
if (
notif.reason === 'repost' ||
notif.reason === 'mention' ||
notif.reason === 'reply' ||
notif.reason === 'quote' ||
notif.reason === 'follow'
) {
return notif.reason as NotificationType
}
return 'unknown'
}
function getSubjectUri(
type: NotificationType,
notif: AppBskyNotificationListNotifications.Notification,
): string | undefined {
if (type === 'reply' || type === 'quote' || type === 'mention') {
return notif.uri
} else if (type === 'post-like' || type === 'repost') {
if (
AppBskyFeedRepost.isRecord(notif.record) ||
AppBskyFeedLike.isRecord(notif.record)
) {
return typeof notif.record.subject?.uri === 'string'
? notif.record.subject?.uri
: undefined
}
}
}
function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
if (!notif.subject) {
return false
}
const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
return mutes.includes(record.reply?.root.uri || notif.subject.uri)
}
+34
View File
@@ -0,0 +1,34 @@
import {
AppBskyNotificationListNotifications,
AppBskyFeedDefs,
} from '@atproto/api'
export type NotificationType =
| 'post-like'
| 'feedgen-like'
| 'repost'
| 'mention'
| 'reply'
| 'quote'
| 'follow'
| 'unknown'
export interface FeedNotification {
_reactKey: string
type: NotificationType
notification: AppBskyNotificationListNotifications.Notification
additional?: AppBskyNotificationListNotifications.Notification[]
subjectUri?: string
subject?: AppBskyFeedDefs.PostView
}
export interface FeedPage {
cursor: string | undefined
items: FeedNotification[]
}
export interface CachedFeedPage {
sessDid: string // used to invalidate on session changes
syncedAt: Date
data: FeedPage | undefined
}
+96 -32
View File
@@ -1,10 +1,20 @@
/**
* A kind of companion API to ./feed.ts. See that file for more info.
*/
import React from 'react'
import * as Notifications from 'expo-notifications'
import {useQueryClient} from '@tanstack/react-query'
import BroadcastChannel from '#/lib/broadcast'
import {useSession, getAgent} from '#/state/session'
import {useModerationOpts} from '../preferences'
import {shouldFilterNotif} from './util'
import {fetchPage} from './util'
import {CachedFeedPage, FeedPage} from './types'
import {isNative} from '#/platform/detection'
import {useMutedThreads} from '#/state/muted-threads'
import {RQKEY as RQKEY_NOTIFS} from './feed'
import {logger} from '#/logger'
import {truncateAndInvalidate} from '../util'
const UPDATE_INTERVAL = 30 * 1e3 // 30sec
@@ -14,7 +24,8 @@ type StateContext = string
interface ApiContext {
markAllRead: () => Promise<void>
checkUnread: () => Promise<void>
checkUnread: (opts?: {invalidate?: boolean}) => Promise<void>
getCachedUnreadPage: () => FeedPage | undefined
}
const stateContext = React.createContext<StateContext>('')
@@ -22,16 +33,23 @@ const stateContext = React.createContext<StateContext>('')
const apiContext = React.createContext<ApiContext>({
async markAllRead() {},
async checkUnread() {},
getCachedUnreadPage: () => undefined,
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const {hasSession} = useSession()
const {hasSession, currentAccount} = useSession()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const [numUnread, setNumUnread] = React.useState('')
const checkUnreadRef = React.useRef<(() => Promise<void>) | null>(null)
const lastSyncRef = React.useRef<Date>(new Date())
const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null)
const cacheRef = React.useRef<CachedFeedPage>({
sessDid: currentAccount?.did || '',
syncedAt: new Date(),
data: undefined,
})
// periodic sync
React.useEffect(() => {
@@ -46,14 +64,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// listen for broadcasts
React.useEffect(() => {
const listener = ({data}: MessageEvent) => {
lastSyncRef.current = new Date()
cacheRef.current = {
sessDid: currentAccount?.did || '',
syncedAt: new Date(),
data: undefined,
}
setNumUnread(data.event)
}
broadcast.addEventListener('message', listener)
return () => {
broadcast.removeEventListener('message', listener)
}
}, [setNumUnread])
}, [setNumUnread, currentAccount])
// create API
const api = React.useMemo<ApiContext>(() => {
@@ -61,7 +83,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
async markAllRead() {
// update server
await getAgent().updateSeenNotifications(
lastSyncRef.current.toISOString(),
cacheRef.current.syncedAt.toISOString(),
)
// update & broadcast
@@ -69,34 +91,59 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
broadcast.postMessage({event: ''})
},
async checkUnread() {
// count
const res = await getAgent().listNotifications({limit: 40})
const filtered = res.data.notifications.filter(
notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts),
)
const num =
filtered.length >= 30
? '30+'
: filtered.length === 0
? ''
: String(filtered.length)
if (isNative) {
Notifications.setBadgeCountAsync(Math.min(filtered.length, 30))
async checkUnread({invalidate}: {invalidate?: boolean} = {}) {
try {
if (!getAgent().session) return
// count
const page = await fetchPage({
cursor: undefined,
limit: 40,
queryClient,
moderationOpts,
threadMutes,
})
const unreadCount = countUnread(page)
const unreadCountStr =
unreadCount >= 30
? '30+'
: unreadCount === 0
? ''
: String(unreadCount)
if (isNative) {
Notifications.setBadgeCountAsync(Math.min(unreadCount, 30))
}
// track last sync
const now = new Date()
const lastIndexed =
page.items[0] && new Date(page.items[0].notification.indexedAt)
cacheRef.current = {
sessDid: currentAccount?.did || '',
data: page,
syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
}
// update & broadcast
setNumUnread(unreadCountStr)
if (invalidate) {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
}
broadcast.postMessage({event: unreadCountStr})
} catch (e) {
logger.error('Failed to check unread notifications', {error: e})
}
},
// track last sync
const now = new Date()
const lastIndexed = filtered[0] && new Date(filtered[0].indexedAt)
lastSyncRef.current =
!lastIndexed || now > lastIndexed ? now : lastIndexed
// update & broadcast
setNumUnread(num)
broadcast.postMessage({event: num})
getCachedUnreadPage() {
// return cached page if was for the current user
// (protects against session changes serving data from the past session)
if (cacheRef.current.sessDid === currentAccount?.did) {
return cacheRef.current.data
}
},
}
}, [setNumUnread, moderationOpts])
}, [setNumUnread, queryClient, moderationOpts, threadMutes, currentAccount])
checkUnreadRef.current = api.checkUnread
return (
@@ -113,3 +160,20 @@ export function useUnreadNotifications() {
export function useUnreadNotificationsApi() {
return React.useContext(apiContext)
}
function countUnread(page: FeedPage) {
let num = 0
for (const item of page.items) {
if (!item.notification.isRead) {
num++
}
if (item.additional) {
for (const item2 of item.additional) {
if (!item2.isRead) {
num++
}
}
}
}
return num
}
+182 -1
View File
@@ -3,10 +3,78 @@ import {
ModerationOpts,
moderateProfile,
moderatePost,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedLike,
} from '@atproto/api'
import chunk from 'lodash.chunk'
import {QueryClient} from '@tanstack/react-query'
import {getAgent} from '../../session'
import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
import {NotificationType, FeedNotification, FeedPage} from './types'
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
const MS_1HR = 1e3 * 60 * 60
const MS_2DAY = MS_1HR * 48
// exported api
// =
export async function fetchPage({
cursor,
limit,
queryClient,
moderationOpts,
threadMutes,
}: {
cursor: string | undefined
limit: number
queryClient: QueryClient
moderationOpts: ModerationOpts | undefined
threadMutes: string[]
}): Promise<FeedPage> {
const res = await getAgent().listNotifications({
limit,
cursor,
})
// filter out notifs by mod rules
const notifs = res.data.notifications.filter(
notif => !shouldFilterNotif(notif, moderationOpts),
)
// group notifications which are essentially similar (follows, likes on a post)
let notifsGrouped = groupNotifications(notifs)
// we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts
const subjects = await fetchSubjects(notifsGrouped)
for (const notif of notifsGrouped) {
if (notif.subjectUri) {
notif.subject = subjects.get(notif.subjectUri)
if (notif.subject) {
precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
}
}
}
// apply thread muting
notifsGrouped = notifsGrouped.filter(
notif => !isThreadMuted(notif, threadMutes),
)
return {
cursor: res.data.cursor,
items: notifsGrouped,
}
}
// internal methods
// =
// TODO this should be in the sdk as moderateNotification -prf
export function shouldFilterNotif(
function shouldFilterNotif(
notif: AppBskyNotificationListNotifications.Notification,
moderationOpts: ModerationOpts | undefined,
): boolean {
@@ -36,3 +104,116 @@ export function shouldFilterNotif(
// (this requires fetching the post)
return false
}
function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[],
): FeedNotification[] {
const groupedNotifs: FeedNotification[] = []
for (const notif of notifs) {
const ts = +new Date(notif.indexedAt)
let grouped = false
if (GROUPABLE_REASONS.includes(notif.reason)) {
for (const groupedNotif of groupedNotifs) {
const ts2 = +new Date(groupedNotif.notification.indexedAt)
if (
Math.abs(ts2 - ts) < MS_2DAY &&
notif.reason === groupedNotif.notification.reason &&
notif.reasonSubject === groupedNotif.notification.reasonSubject &&
notif.author.did !== groupedNotif.notification.author.did &&
notif.isRead === groupedNotif.notification.isRead
) {
groupedNotif.additional = groupedNotif.additional || []
groupedNotif.additional.push(notif)
grouped = true
break
}
}
}
if (!grouped) {
const type = toKnownType(notif)
groupedNotifs.push({
_reactKey: `notif-${notif.uri}`,
type,
notification: notif,
subjectUri: getSubjectUri(type, notif),
})
}
}
return groupedNotifs
}
async function fetchSubjects(
groupedNotifs: FeedNotification[],
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>()
for (const notif of groupedNotifs) {
if (notif.subjectUri) {
uris.add(notif.subjectUri)
}
}
const uriChunks = chunk(Array.from(uris), 25)
const postsChunks = await Promise.all(
uriChunks.map(uris =>
getAgent()
.app.bsky.feed.getPosts({uris})
.then(res => res.data.posts),
),
)
const map = new Map<string, AppBskyFeedDefs.PostView>()
for (const post of postsChunks.flat()) {
if (
AppBskyFeedPost.isRecord(post.record) &&
AppBskyFeedPost.validateRecord(post.record).success
) {
map.set(post.uri, post)
}
}
return map
}
function toKnownType(
notif: AppBskyNotificationListNotifications.Notification,
): NotificationType {
if (notif.reason === 'like') {
if (notif.reasonSubject?.includes('feed.generator')) {
return 'feedgen-like'
}
return 'post-like'
}
if (
notif.reason === 'repost' ||
notif.reason === 'mention' ||
notif.reason === 'reply' ||
notif.reason === 'quote' ||
notif.reason === 'follow'
) {
return notif.reason as NotificationType
}
return 'unknown'
}
function getSubjectUri(
type: NotificationType,
notif: AppBskyNotificationListNotifications.Notification,
): string | undefined {
if (type === 'reply' || type === 'quote' || type === 'mention') {
return notif.uri
} else if (type === 'post-like' || type === 'repost') {
if (
AppBskyFeedRepost.isRecord(notif.record) ||
AppBskyFeedLike.isRecord(notif.record)
) {
return typeof notif.record.subject?.uri === 'string'
? notif.record.subject?.uri
: undefined
}
}
}
function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
if (!notif.subject) {
return false
}
const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
return mutes.includes(record.reply?.root.uri || notif.subject.uri)
}
+222 -101
View File
@@ -1,9 +1,14 @@
import {useCallback, useMemo} from 'react'
import {useCallback} from 'react'
import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '../session'
import {
useInfiniteQuery,
InfiniteData,
QueryKey,
QueryClient,
useQueryClient,
} from '@tanstack/react-query'
import {useFeedTuners} from '../preferences/feed-tuners'
import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
import {FollowingFeedAPI} from 'lib/api/feed/following'
import {AuthorFeedAPI} from 'lib/api/feed/author'
@@ -11,9 +16,13 @@ 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 {useModerationOpts} from '#/state/queries/preferences'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri'
import {getAgent} from '#/state/session'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
type ActorDid = string
type AuthorFilter =
@@ -35,7 +44,7 @@ export interface FeedParams {
mergeFeedSources?: string[]
}
type RQPageParam = string | undefined
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
return ['post-feed', feedDesc, params || {}]
@@ -56,7 +65,15 @@ export interface FeedPostSlice {
items: FeedPostSliceItem[]
}
export interface FeedPageUnselected {
api: FeedAPI
cursor: string | undefined
feed: AppBskyFeedDefs.FeedViewPost[]
}
export interface FeedPage {
api: FeedAPI
tuner: FeedTuner | NoopFeedTuner
cursor: string | undefined
slices: FeedPostSlice[]
}
@@ -66,117 +83,221 @@ export function usePostFeedQuery(
params?: FeedParams,
opts?: {enabled?: boolean},
) {
const queryClient = useQueryClient()
const feedTuners = useFeedTuners(feedDesc)
const enabled = opts?.enabled !== false
const moderationOpts = useModerationOpts()
const agent = getAgent()
const api: FeedAPI = useMemo(() => {
if (feedDesc === 'home') {
return new MergeFeedAPI(agent, params || {}, feedTuners)
} else if (feedDesc === 'following') {
return new FollowingFeedAPI(agent)
} else if (feedDesc.startsWith('author')) {
const [_, actor, filter] = feedDesc.split('|')
return new AuthorFeedAPI(agent, {actor, filter})
} else if (feedDesc.startsWith('likes')) {
const [_, actor] = feedDesc.split('|')
return new LikesFeedAPI(agent, {actor})
} else if (feedDesc.startsWith('feedgen')) {
const [_, feed] = feedDesc.split('|')
return new CustomFeedAPI(agent, {feed})
} else if (feedDesc.startsWith('list')) {
const [_, list] = feedDesc.split('|')
return new ListFeedAPI(agent, {list})
} else {
// shouldnt happen
return new FollowingFeedAPI(agent)
}
}, [feedDesc, params, feedTuners, agent])
const disableTuner = !!params?.disableTuner
const tuner = useMemo(
() => (disableTuner ? new NoopFeedTuner() : new FeedTuner()),
[disableTuner],
)
const pollLatest = useCallback(async () => {
if (!enabled) {
return false
}
logger.debug('usePostFeedQuery: pollLatest')
const post = await api.peekLatest()
if (post && moderationOpts) {
const slices = tuner.tune([post], feedTuners, {
dryRun: true,
maintainOrder: true,
})
if (slices[0]) {
if (
!moderatePost(slices[0].items[0].post, moderationOpts).content.filter
) {
return true
}
}
}
return false
}, [api, tuner, feedTuners, moderationOpts, enabled])
const out = useInfiniteQuery<
FeedPage,
return useInfiniteQuery<
FeedPageUnselected,
Error,
InfiniteData<FeedPage>,
QueryKey,
RQPageParam
>({
enabled,
staleTime: STALE.INFINITY,
queryKey: RQKEY(feedDesc, params),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
console.log('fetch', feedDesc, pageParam)
if (!pageParam) {
tuner.reset()
logger.debug('usePostFeedQuery', {feedDesc, pageParam})
const {api, cursor} = pageParam
? pageParam
: {
api: createApi(feedDesc, params || {}, feedTuners),
cursor: undefined,
}
const res = await api.fetch({cursor, limit: 30})
precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution
/*
* If this is a public view, we need to check if posts fail moderation.
* If all fail, we throw an error. If only some fail, we continue and let
* moderations happen later, which results in some posts being shown and
* some not.
*/
if (!getAgent().session) {
assertSomePostsPassModeration(res.feed)
}
const res = await api.fetch({cursor: pageParam, limit: 30})
const slices = tuner.tune(res.feed, feedTuners)
return {
api,
cursor: res.cursor,
slices: slices.map(slice => ({
_reactKey: slice._reactKey,
rootUri: slice.rootItem.post.uri,
isThread:
slice.items.length > 1 &&
slice.items.every(
item => item.post.author.did === slice.items[0].post.author.did,
),
source: undefined, // TODO
items: slice.items
.map((item, i) => {
if (
AppBskyFeedPost.isRecord(item.post.record) &&
AppBskyFeedPost.validateRecord(item.post.record).success
) {
return {
_reactKey: `${slice._reactKey}-${i}`,
uri: item.post.uri,
post: item.post,
record: item.post.record,
reason: i === 0 && slice.source ? slice.source : item.reason,
}
}
return undefined
})
.filter(Boolean) as FeedPostSliceItem[],
})),
feed: res.feed,
}
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
enabled,
getNextPageParam: lastPage =>
lastPage.cursor
? {
api: lastPage.api,
cursor: lastPage.cursor,
}
: undefined,
select: useCallback(
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
const tuner = params?.disableTuner
? new NoopFeedTuner()
: new FeedTuner(feedTuners)
return {
pageParams: data.pageParams,
pages: data.pages.map(page => ({
api: page.api,
tuner,
cursor: page.cursor,
slices: tuner.tune(page.feed).map(slice => ({
_reactKey: slice._reactKey,
rootUri: slice.rootItem.post.uri,
isThread:
slice.items.length > 1 &&
slice.items.every(
item =>
item.post.author.did === slice.items[0].post.author.did,
),
items: slice.items
.map((item, i) => {
if (
AppBskyFeedPost.isRecord(item.post.record) &&
AppBskyFeedPost.validateRecord(item.post.record).success
) {
return {
_reactKey: `${slice._reactKey}-${i}`,
uri: item.post.uri,
post: item.post,
record: item.post.record,
reason:
i === 0 && slice.source ? slice.source : item.reason,
}
}
return undefined
})
.filter(Boolean) as FeedPostSliceItem[],
})),
})),
}
},
[feedTuners, params?.disableTuner],
),
})
return {...out, pollLatest}
}
export async function pollLatest(page: FeedPage | undefined) {
if (!page) {
return false
}
logger.debug('usePostFeedQuery: pollLatest')
const post = await page.api.peekLatest()
if (post) {
const slices = page.tuner.tune([post], {
dryRun: true,
maintainOrder: true,
})
if (slices[0]) {
return true
}
}
return false
}
function createApi(
feedDesc: FeedDescriptor,
params: FeedParams,
feedTuners: FeedTunerFn[],
) {
if (feedDesc === 'home') {
return new MergeFeedAPI(params, feedTuners)
} else if (feedDesc === 'following') {
return new FollowingFeedAPI()
} else if (feedDesc.startsWith('author')) {
const [_, actor, filter] = feedDesc.split('|')
return new AuthorFeedAPI({actor, filter})
} else if (feedDesc.startsWith('likes')) {
const [_, actor] = feedDesc.split('|')
return new LikesFeedAPI({actor})
} else if (feedDesc.startsWith('feedgen')) {
const [_, feed] = feedDesc.split('|')
return new CustomFeedAPI({feed})
} else if (feedDesc.startsWith('list')) {
const [_, list] = feedDesc.split('|')
return new ListFeedAPI({list})
} else {
// shouldnt happen
return new FollowingFeedAPI()
}
}
/**
* This helper is used by the post-thread placeholder function to
* find a post in the query-data cache
*/
export function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): AppBskyFeedDefs.PostView | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
}
}
export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<AppBskyFeedDefs.PostView, undefined> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<FeedPageUnselected>
>({
queryKey: ['post-feed'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const item of page.feed) {
if (item.post.uri === uri) {
yield item.post
}
if (
AppBskyFeedDefs.isPostView(item.reply?.parent) &&
item.reply?.parent?.uri === uri
) {
yield item.reply.parent
}
if (
AppBskyFeedDefs.isPostView(item.reply?.root) &&
item.reply?.root?.uri === uri
) {
yield item.reply.root
}
}
}
}
}
function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
// assume false
let somePostsPassModeration = false
for (const item of feed) {
const moderationOpts = getModerationOpts({
userDid: '',
preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
})
const moderation = moderatePost(item.post, moderationOpts)
if (!moderation.content.filter) {
// we have a sfw post
somePostsPassModeration = true
}
}
if (!somePostsPassModeration) {
throw new Error(KnownError.FeedNSFPublic)
}
}
+30 -4
View File
@@ -1,8 +1,12 @@
import {AppBskyFeedGetLikes} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {AppBskyActorDefs, AppBskyFeedGetLikes} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -18,7 +22,6 @@ export function usePostLikedByQuery(resolvedUri: string | undefined) {
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(resolvedUri || ''),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().getLikes({
@@ -33,3 +36,26 @@ export function usePostLikedByQuery(resolvedUri: string | undefined) {
enabled: !!resolvedUri,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyFeedGetLikes.OutputSchema>
>({
queryKey: ['post-liked-by'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const like of page.likes) {
if (like.actor.did === did) {
yield like.actor
}
}
}
}
}
+30 -4
View File
@@ -1,8 +1,12 @@
import {AppBskyFeedGetRepostedBy} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {AppBskyActorDefs, AppBskyFeedGetRepostedBy} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -18,7 +22,6 @@ export function usePostRepostedByQuery(resolvedUri: string | undefined) {
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(resolvedUri || ''),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().getRepostedBy({
@@ -33,3 +36,26 @@ export function usePostRepostedByQuery(resolvedUri: string | undefined) {
enabled: !!resolvedUri,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyFeedGetRepostedBy.OutputSchema>
>({
queryKey: ['post-reposted-by'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const repostedBy of page.repostedBy) {
if (repostedBy.did === did) {
yield repostedBy
}
}
}
}
}
+135 -7
View File
@@ -3,11 +3,13 @@ import {
AppBskyFeedPost,
AppBskyFeedGetPostThread,
} from '@atproto/api'
import {useQuery} from '@tanstack/react-query'
import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {STALE} from '#/state/queries'
import {findPostInQueryData as findPostInFeedQueryData} from './post-feed'
import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri'
export const RQKEY = (uri: string) => ['post-thread', uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
@@ -18,6 +20,8 @@ export interface ThreadCtx {
hasMore?: boolean
showChildReplyLine?: boolean
showParentReplyLine?: boolean
isParentLoading?: boolean
isChildLoading?: boolean
}
export type ThreadPost = {
@@ -58,17 +62,43 @@ export type ThreadNode =
| ThreadUnknown
export function usePostThreadQuery(uri: string | undefined) {
const queryClient = useQueryClient()
return useQuery<ThreadNode, Error>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(uri || ''),
async queryFn() {
const res = await getAgent().getPostThread({uri: uri!})
if (res.success) {
return responseToThreadNodes(res.data.thread)
const nodes = responseToThreadNodes(res.data.thread)
precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution
return nodes
}
return {type: 'unknown', uri: uri!}
},
enabled: !!uri,
placeholderData: () => {
if (!uri) {
return undefined
}
{
const item = findPostInQueryData(queryClient, uri)
if (item) {
return threadNodeToPlaceholderThread(item)
}
}
{
const item = findPostInFeedQueryData(queryClient, uri)
if (item) {
return postViewToPlaceholderThread(item)
}
}
{
const item = findPostInNotifsQueryData(queryClient, uri)
if (item) {
return postViewToPlaceholderThread(item)
}
}
return undefined
},
})
}
@@ -151,9 +181,10 @@ function responseToThreadNodes(
: undefined,
replies:
node.replies?.length && direction !== 'up'
? node.replies.map(reply =>
responseToThreadNodes(reply, depth + 1, 'down'),
)
? node.replies
.map(reply => responseToThreadNodes(reply, depth + 1, 'down'))
// do not show blocked posts in replies
.filter(node => node.type !== 'blocked')
: undefined,
viewer: node.viewer,
ctx: {
@@ -177,3 +208,100 @@ function responseToThreadNodes(
return {type: 'unknown', uri: ''}
}
}
function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): ThreadNode | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
}
}
export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<ThreadNode, void> {
const queryDatas = queryClient.getQueriesData<ThreadNode>({
queryKey: ['post-thread'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) {
continue
}
for (const item of traverseThread(queryData)) {
if (item.uri === uri) {
yield item
}
}
}
}
function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
if (node.type === 'post') {
if (node.parent) {
yield* traverseThread(node.parent)
}
yield node
if (node.replies?.length) {
for (const reply of node.replies) {
yield* traverseThread(reply)
}
}
}
}
function threadNodeToPlaceholderThread(
node: ThreadNode,
): ThreadNode | undefined {
if (node.type !== 'post') {
return undefined
}
return {
type: node.type,
_reactKey: node._reactKey,
uri: node.uri,
post: node.post,
record: node.record,
parent: undefined,
replies: undefined,
viewer: node.viewer,
ctx: {
depth: 0,
isHighlightedPost: true,
hasMore: false,
showChildReplyLine: false,
showParentReplyLine: false,
isParentLoading: !!node.record.reply,
isChildLoading: !!node.post.replyCount,
},
}
}
function postViewToPlaceholderThread(
post: AppBskyFeedDefs.PostView,
): ThreadNode {
return {
type: 'post',
_reactKey: post.uri,
uri: post.uri,
post: post,
record: post.record as AppBskyFeedPost.Record, // validated in notifs
parent: undefined,
replies: undefined,
viewer: post.viewer,
ctx: {
depth: 0,
isHighlightedPost: true,
hasMore: false,
showChildReplyLine: false,
showParentReplyLine: false,
isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply,
isChildLoading: !!post.replyCount,
},
}
}
-3
View File
@@ -4,13 +4,11 @@ import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {updatePostShadow} from '#/state/cache/post-shadow'
import {STALE} from '#/state/queries'
export const RQKEY = (postUri: string) => ['post', postUri]
export function usePostQuery(uri: string | undefined) {
return useQuery<AppBskyFeedDefs.PostView>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(uri || ''),
async queryFn() {
const res = await getAgent().getPosts({uris: [uri!]})
@@ -29,7 +27,6 @@ export function useGetPost() {
return React.useCallback(
async ({uri}: {uri: string}) => {
return queryClient.fetchQuery({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(uri || ''),
async queryFn() {
const urip = new AtUri(uri)
+24
View File
@@ -2,6 +2,7 @@ import {
UsePreferencesQueryResponse,
ThreadViewPreferences,
} from '#/state/queries/preferences/types'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
{
@@ -25,3 +26,26 @@ export const DEFAULT_PROD_FEEDS = {
pinned: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
saved: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
}
export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
birthDate: new Date('2022-11-17'), // TODO(pwi)
adultContentEnabled: false,
feeds: {
saved: [],
pinned: [],
unpinned: [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw,
nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity,
suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive,
gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore,
hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate,
spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam,
impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation,
},
feedViewPrefs: DEFAULT_HOME_FEED_PREFS,
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
userAge: 13, // TODO(pwi)
}
+72 -77
View File
@@ -1,11 +1,6 @@
import {useEffect, useState} from 'react'
import {useMemo} from 'react'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {
LabelPreference,
BskyFeedViewPreference,
ModerationOpts,
} from '@atproto/api'
import isEqual from 'lodash.isequal'
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
import {getAge} from '#/lib/strings/time'
@@ -20,6 +15,7 @@ import {temp__migrateLabelPref} from '#/state/queries/preferences/util'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_THREAD_VIEW_PREFS,
DEFAULT_LOGGED_OUT_PREFERENCES,
} from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {STALE} from '#/state/queries'
@@ -28,84 +24,83 @@ export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation'
export * from '#/state/queries/preferences/const'
export const usePreferencesQueryKey = ['getPreferences']
export const preferencesQueryKey = ['getPreferences']
export function usePreferencesQuery() {
const {hasSession} = useSession()
return useQuery({
enabled: hasSession,
staleTime: STALE.MINUTES.ONE,
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
queryFn: async () => {
const res = await getAgent().getPreferences()
const preferences: UsePreferencesQueryResponse = {
...res,
feeds: {
saved: res.feeds?.saved || [],
pinned: res.feeds?.pinned || [],
unpinned:
res.feeds.saved?.filter(f => {
return !res.feeds.pinned?.includes(f)
}) || [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: temp__migrateLabelPref(
res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
),
nudity: temp__migrateLabelPref(
res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
),
suggestive: temp__migrateLabelPref(
res.contentLabels?.suggestive ||
DEFAULT_LABEL_PREFERENCES.suggestive,
),
gore: temp__migrateLabelPref(
res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
),
hate: temp__migrateLabelPref(
res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
),
spam: temp__migrateLabelPref(
res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
),
impersonation: temp__migrateLabelPref(
res.contentLabels?.impersonation ||
DEFAULT_LABEL_PREFERENCES.impersonation,
),
},
feedViewPrefs: {
...DEFAULT_HOME_FEED_PREFS,
...(res.feedViewPrefs.home || {}),
},
threadViewPrefs: {
...DEFAULT_THREAD_VIEW_PREFS,
...(res.threadViewPrefs ?? {}),
},
userAge: res.birthDate ? getAge(res.birthDate) : undefined,
const agent = getAgent()
if (agent.session?.did === undefined) {
return DEFAULT_LOGGED_OUT_PREFERENCES
} else {
const res = await agent.getPreferences()
const preferences: UsePreferencesQueryResponse = {
...res,
feeds: {
saved: res.feeds?.saved || [],
pinned: res.feeds?.pinned || [],
unpinned:
res.feeds.saved?.filter(f => {
return !res.feeds.pinned?.includes(f)
}) || [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: temp__migrateLabelPref(
res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
),
nudity: temp__migrateLabelPref(
res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
),
suggestive: temp__migrateLabelPref(
res.contentLabels?.suggestive ||
DEFAULT_LABEL_PREFERENCES.suggestive,
),
gore: temp__migrateLabelPref(
res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
),
hate: temp__migrateLabelPref(
res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
),
spam: temp__migrateLabelPref(
res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
),
impersonation: temp__migrateLabelPref(
res.contentLabels?.impersonation ||
DEFAULT_LABEL_PREFERENCES.impersonation,
),
},
feedViewPrefs: {
...DEFAULT_HOME_FEED_PREFS,
...(res.feedViewPrefs.home || {}),
},
threadViewPrefs: {
...DEFAULT_THREAD_VIEW_PREFS,
...(res.threadViewPrefs ?? {}),
},
userAge: res.birthDate ? getAge(res.birthDate) : undefined,
}
return preferences
}
return preferences
},
})
}
export function useModerationOpts() {
const {currentAccount} = useSession()
const [opts, setOpts] = useState<ModerationOpts | undefined>()
const prefs = usePreferencesQuery()
useEffect(() => {
const opts = useMemo(() => {
if (!prefs.data) {
return
}
// only update this hook when the moderation options change
const newOpts = getModerationOpts({
return getModerationOpts({
userDid: currentAccount?.did || '',
preferences: prefs.data,
})
if (!isEqual(opts, newOpts)) {
setOpts(newOpts)
}
}, [prefs.data, currentAccount, opts, setOpts])
}, [currentAccount?.did, prefs.data])
return opts
}
@@ -117,7 +112,7 @@ export function useClearPreferencesMutation() {
await getAgent().app.bsky.actor.putPreferences({preferences: []})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -135,7 +130,7 @@ export function usePreferencesSetContentLabelMutation() {
await getAgent().setContentLabelPref(labelGroup, visibility)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -149,7 +144,7 @@ export function usePreferencesSetAdultContentMutation() {
await getAgent().setAdultContentEnabled(enabled)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -163,7 +158,7 @@ export function usePreferencesSetBirthDateMutation() {
await getAgent().setPersonalDetails({birthDate})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -177,7 +172,7 @@ export function useSetFeedViewPreferencesMutation() {
await getAgent().setFeedViewPrefs('home', prefs)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -191,7 +186,7 @@ export function useSetThreadViewPreferencesMutation() {
await getAgent().setThreadViewPrefs(prefs)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -209,7 +204,7 @@ export function useSetSaveFeedsMutation() {
await getAgent().setSavedFeeds(saved, pinned)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -224,7 +219,7 @@ export function useSaveFeedMutation() {
track('CustomFeed:Save')
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -239,7 +234,7 @@ export function useRemoveFeedMutation() {
track('CustomFeed:Unsave')
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -254,7 +249,7 @@ export function usePinFeedMutation() {
track('CustomFeed:Pin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -269,7 +264,7 @@ export function useUnpinFeedMutation() {
track('CustomFeed:Unpin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -34,6 +34,24 @@ export const DEFAULT_LABEL_PREFERENCES: Record<
impersonation: 'hide',
}
/**
* More strict than our default settings for logged in users.
*
* TODO(pwi)
*/
export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record<
ConfigurableLabelGroup,
LabelPreference
> = {
nsfw: 'hide',
nudity: 'hide',
suggestive: 'hide',
gore: 'hide',
hate: 'hide',
spam: 'hide',
impersonation: 'hide',
}
export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = {
id: 'illegal',
title: 'Illegal Content',
+4 -1
View File
@@ -43,7 +43,10 @@ export type UsePreferencesQueryResponse = Omit<
}
}
export type ThreadViewPreferences = Omit<BskyThreadViewPreference, 'sort'> & {
export type ThreadViewPreferences = Pick<
BskyThreadViewPreference,
'prioritizeFollowedUsers'
> & {
sort: 'oldest' | 'newest' | 'most-likes' | 'random' | string
lab_treeViewEnabled?: boolean
}
-2
View File
@@ -2,7 +2,6 @@ import {AppBskyFeedGetActorFeeds} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -22,7 +21,6 @@ export function useProfileFeedgensQuery(
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(did),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().app.bsky.feed.getActorFeeds({
+30 -4
View File
@@ -1,8 +1,12 @@
import {AppBskyGraphGetFollowers} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {AppBskyActorDefs, AppBskyGraphGetFollowers} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -17,7 +21,6 @@ export function useProfileFollowersQuery(did: string | undefined) {
QueryKey,
RQPageParam
>({
staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY(did || ''),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().app.bsky.graph.getFollowers({
@@ -32,3 +35,26 @@ export function useProfileFollowersQuery(did: string | undefined) {
enabled: !!did,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetFollowers.OutputSchema>
>({
queryKey: ['profile-followers'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const follower of page.followers) {
if (follower.did === did) {
yield follower
}
}
}
}
}
+30 -2
View File
@@ -1,5 +1,10 @@
import {AppBskyGraphGetFollows} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {AppBskyActorDefs, AppBskyGraphGetFollows} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryClient,
QueryKey,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
@@ -33,3 +38,26 @@ export function useProfileFollowsQuery(did: string | undefined) {
enabled: !!did,
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetFollows.OutputSchema>
>({
queryKey: ['profile-follows'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const follow of page.follows) {
if (follow.did === did) {
yield follow
}
}
}
}
}

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