Compare commits

...

195 Commits

Author SHA1 Message Date
Eric Bailey 2c9208cc1c Testing 2023-11-29 18:02:00 -06:00
Paul Frazee fcd1a62e62 Move post-feed query state into the query's page params to consistently bind their lifecycles 2023-11-29 13:05:06 -08:00
Paul Frazee 1c341e0a54 Assign keys to feed slices via a counter, to enable duplicate items in the feed if needed 2023-11-29 13:04:04 -08: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
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
dan 6c8f043e37 Freeze inactive screens (#1949) 2023-11-16 22:20:08 -08:00
dan a8aed2c38e Use non-reactive callbacks for some methods (#1948)
* Use non-reactive callbacks for some methods

* Remove unnecessary state

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

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

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

* Fix invalidation of notifs

* Move spinner below header

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

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

* Rm unused

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

* Track both current and pending actions

* Skip unnecessary actions

* Commit last confirmed state to shadow

* Thread state through actions over time

* Fix the logic to skip redundant mutations

* Track status

* Extract an abstraction

* Fix standalone mutations

* Add types

* Move to another file

* Return stable function

* Clean up

* Use queue for muting

* Use queue for blocking

* Convert other follow buttons

* Don't export non-queue mutations

* Properly handle canceled tasks

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

* Update modal to use session instead of store

* Move image sizes cache off store

* Update settings to no longer use store

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

* Remove deprecated resolveName()

* Delete deprecated link-metas cache

* Delete deprecated posts cache

* Delete all remaining mobx models, including the root store

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

* Add translations

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

* Replace delete account logic

* Replace me model in bottom bar web

* Replace me model in bottom bar

* Replace me model in reply prompt

* Better fallback

* Fix reference

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

* Better telemetry

* Remove unused logic

* Remove caching

* Add error message

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

* Replace in drawer

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

* Add close lightbox

* Switch to hooks

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

* Cleanup

* Remove my-follows cache

* Replace moderationOpts in ProfileCard comp

* Replace moderationOpts in FeedSlice

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

* Remove post-thread-item

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

* Move soft-reset event out of the shell model

* Update soft-reset listener on new search page

* Implement session-loaded and session-dropped events

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

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

* Mobile search

* Dedupe suggestions

* Clean up and reorg

* Cleanup

* Cleanup

* Use Pager

* Delete unused code

* Fix conflicts

* Remove search ui model

* Soft reset

* Fix scrollable results, remove observer

* Use correct ScrollView

* Clean up layout

---------

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

* Persist autocomplete results while they're in progress

* Commit lockfile

* Use ReturnType helper

---------

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

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

* Port user follows to RQ

* Start porting FollowButton to RQ

* Fix RQ key

* Check pending

* Fix shadow and pending states

* Rm unused

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

* Remove log

* Add actor typeahead handling

* Trim down desktop search styles and hooks

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

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

* Use queryClient

* Fix type

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

* Rework composer to use RQ

---------

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

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

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

* Fix error state, track call

* Remove todo

* Use flatmap

* Add additional try catch

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

* Update ProfileCard to use the profile shadow cache and useModerationOpts

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

* Fix keys

* Add missing behaviors to post tabs

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

* Replace old FeedSourceCard

* Clean up CustomFeedEmbed

* Remove discover feeds model

* Refactor ProfileFeed screen

* Remove useCustomFeed

* Delete some unused models

* Rip out more prefs

* Factor out treeView from thread comp

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

* Update profile editor

* Add dynamic likes tab

* Add dynamic feeds and lists tabs

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

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

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

* Cleanup

---------

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

* Use queryClient to fetch

* Replace useHomeTabs and useDesktopRightNavItems

* Fix type error

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

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

* Add useModerationOpts hook

* Refactor notifications to use react-query

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

* Implement unread notification tracking

* Add moderation filtering to notifications

* Handle native/push notifications

* Remove dead code

---------

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

* Remove unused feed UI models

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

* Delete old lists-list model

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

* Update CreateOrEditList modal

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

* Update ListAddUserModal to use new queries, change to ListAddRemoveUsersModal

* Update UserAddRemoveLists modal

* Remove old TODO

* Fix indent, autocomplete query

* Add a todo

---------

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

* Remove unused

* Clean up labels, migrate getModerationOpts

* Add birth date handling

* Migrate feed prefs

* Migrate thread view prefs

* Migrate homeFeed to use existing key name

* Fix up saved feeds in response, no impl yet

* Migrate saved feeds to new hooks

* Clean up more of preferences

* Fix PreferencesThreads load state

* Fix modal dismissal

* Small spacing fix

---------

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

* Introduce post-feed react query

* Add feed refresh behaviors

* Only fetch feeds of visible pages

* Implement polling for latest on feeds

* Add moderation filtering to slices

* Handle block errors

* Update feed error messages

* Remove old models

* Replace simple-feed option with disable-tuner option

* Add missing useMemo

* Implement the mergefeed and fixes to polling

* Correctly handle failed load more state

* Improve error and empty state behaviors

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

* Force Feed to always take handlers

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

This reverts commit ecebb78e40.

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

* Update PostThread components to use new queries

* Move from normalized cache to shadow cache model

* Merge post shadow into the post automatically

* Remove dead code

* Remove old temporary session

* Fix: set agent on session creation

* Temporarily double-login

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

* TODOs

* Fix recursion

* Couple more things

* Add back resume session concept

* Handle ready

* Cleanup of initial loading states

* Handle init failure

* Cleanup

* Remove account

* Add updateCurrentAccount

* Remove log

* Cleanup

* Integrate removeAccount

* Add hasSession

* Add to App.native, harden migration

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

* Make shell mode a float

* Translate based on the gesture

* Track header and footer heights

* Add web support

* Fix types and cleanup

* Add back isScrolled logic

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

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

* setup dynamic locale activation and async loading

* first pass of automated replacement of text messages

* add some more documentaton

* fix nits

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

* make accessibilityLabel localized

* compile and extract new messages

* fix merge conflicts

* fix eslint warning

* change instructions from sending email to opening PR

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

* Remove unused code

* Change shell mode without re-renders

* Adjust "write your reply" for mode
2023-11-09 00:25:27 +00:00
Eric Bailey bd531f2344 Pull language methods into api context (#1847)
* Pull language methods into api context

* Rename for consistency
2023-11-08 11:55:38 -08:00
Eric Bailey f18b15241a Add modal state provider, replace usage except methods (#1833)
* Add modal state provider, replace usage except methods

* Replace easy spots

* Fix sticky spots

* Replace final usages

* Memorize context objects

* Add more warnings
2023-11-08 10:34:10 -08:00
Paul Frazee 5eadadffbf State layer cleanup: move alt-text-required into preferences and fix a bug in reminders (#1845)
* Move alt-text-required into preferences

* Fix bug: reminders now using new onboarding state
2023-11-08 09:46:07 -08:00
Paul Frazee 5843e212c0 Move language preferences to new persistence + context (#1837) 2023-11-08 09:38:28 -08:00
Paul Frazee e75b2d508b Move invite-state to new persistence + context and replace the notifications with just showing uses in the modal (#1840) 2023-11-08 09:10:59 -08:00
Paul Frazee 74f8390f1d Move muted threads to new persistence + context (#1838) 2023-11-08 09:08:42 -08:00
Paul Frazee 4afed4be28 Move onboarding state to new persistence + reducer context (#1835) 2023-11-08 09:04:06 -08:00
Paul Frazee 3a211017d3 Move require alt-text to new persistence + context (#1839) 2023-11-08 08:52:01 -08:00
Paul Frazee 2acc88e78d Move reminders to new persisted state layer (#1834) 2023-11-07 16:39:13 -08:00
Eric Bailey 96d8faf4b0 Add persistent state provider (#1830)
* Add persistent state provider

* Catch write error

* Handle read errors, update error msgs

* Fix lint

* Don't provide initial state to loader

* Remove colorMode from shell state

* Idea: hook into persisted context from other files

* Migrate settings to new hook

* Rework persisted state to split individual contexts

* Tweak persisted schema and validation

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2023-11-07 14:06:17 -08:00
Eric Bailey bfe196bac5 Extract shell state into separate context (#1824)
* WIP

* Add shell state

* Integrate new shell state for drawer and minimal shell mode

* Replace isDrawerSwipeDisabled

* Split shell state into separate contexts to avoid needless re-renders

* Fix typo

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2023-11-07 11:37:47 -08:00
Noritada Kobayashi 7158157f5f Correct meta tag attributes (#1829) 2023-11-07 09:56:44 -08:00
dan 7b2a7db83c Pull animated scroll handler down from pager (#1827) 2023-11-07 08:46:39 -08:00
dan fa821943da Use Sentry only for errors (#1776)
* Use Sentry only for errors

* Fix merge
2023-11-07 04:30:00 +00:00
Eric Bailey 7ffbee18b5 Fix removal of old lists from saved feeds (#1823)
* Fix removal of old lists from saved feeds

* Fix saved feed removal race condition
2023-11-06 18:51:46 -08:00
dan a4baf14e4b Fix accidental unwrapped strings (#1826) 2023-11-06 14:42:25 -08:00
dan d715246e26 Fix sticky pager jumps (#1825)
* Defer showing pager content until its header settles

* Introduce the concept of headerOnlyHeight

* Keep headerOnlyHeight in state, make headerHeight derived

* Hide content until *both* header (only) and tabbar are measured

* Hide tabbar to read its layout earlier

* Give consistent keys to pages
2023-11-06 22:30:10 +00:00
dan 4c00fc576d Fix minor list layout bugs (#1810)
* Fix accidental border on mobile

* Fix lists list to take full screen
2023-11-06 17:15:01 +00:00
Eric Bailey 95b5e642dc Merge pull request #1814 from bluesky-social/eric/replace-logger
Remove old logger
2023-11-04 14:00:38 -05:00
Eric Bailey f6fe980a64 Remove old logger 2023-11-04 13:43:28 -05:00
Eric Bailey f51351e80d Replace all logs with new logger 2023-11-04 13:42:36 -05:00
Eric Bailey e49a3d8a56 Merge pull request #1813 from bluesky-social/eric/app-903-extract-logger-into-singleton
Add new logger
2023-11-04 13:12:46 -05:00
Eric Bailey 0c76866757 Add logDump tests, ensure Log screen is reversed 2023-11-04 13:09:12 -05:00
Eric Bailey 7e29ebbadb Fix other error logs while I'm at it 2023-11-04 12:58:50 -05:00
Eric Bailey df0dcf32f9 Fix immediate TS errors 2023-11-04 12:42:27 -05:00
Eric Bailey 5fd41ad5a2 Comment out sentry in prod for now 2023-11-04 12:28:30 -05:00
Eric Bailey fd93bf2146 Add new logger 2023-11-04 12:18:56 -05:00
Paul Frazee 46c2564e65 Increment iOS buildnumber & android version code for release 2023-11-03 16:44:59 -07:00
Paul Frazee ebad6d2b1a ListAddUser modal UX improvements (#1809)
* typo

* Add loading state to ListAddUser

* Improve UI/UX of ListAddUser
2023-11-03 16:44:00 -07:00
Paul Frazee 4de852ded8 Refresh on scroll-to-top by active section press (#1807) 2023-11-03 14:53:47 -07:00
Paul Frazee 445f976881 Improved list and feed errors (#1798)
* Fix error-state rendering of ProfileList

* Unsave/unpin lists on delete

* Improve handling of failing feedgens

* Only show 'remove' btn on feed DNE
2023-11-03 14:18:44 -07:00
Eric Bailey 691af26895 Add subpath imports (#1808) 2023-11-03 14:17:40 -07:00
Paul Frazee 047f74c67d Fix copyright report link (#1799) 2023-11-02 22:09:31 -07:00
Paul Frazee 9437d743e2 Bump ios build number 2023-11-02 19:55:15 -07:00
Paul Frazee 846c130706 Lookup did for my profile at screen to avoid bad actor error (#1800) 2023-11-02 19:54:43 -07:00
dan 8747c215b1 Work around a startup stall caused by expo-image on low-end Android (#1801)
* Use plain RN image for startup-blocking UI

* Add comments
2023-11-02 19:33:18 -07:00
Paul Frazee 5e2025e60a Bump @atproto/api@0.6.23 2023-11-02 18:27:20 -07:00
Eric Bailey f258289be9 Treat PHP pages as HTML (#1797) 2023-11-02 15:16:31 -07:00
Paul Frazee 0f4bfcb05d Replace draglist due to upstream errors (#1795)
* Replace draggable flatlist with simple sort buttons

* Remove react-native-draggable-flatlist dep

* Fix hitslops

* Update lockfile

* Remove bad flex:1
2023-11-02 12:09:57 -07:00
dan 0e8723c3bb Patch facebook/metro#1130 to profile module init (#1796) 2023-11-02 18:48:07 +00:00
Paul Frazee 237bce391b Merge branch 'main' of github.com:bluesky-social/social-app into main 2023-11-02 09:43:47 -07:00
Paul Frazee c8b9407e0e Merge branch 'flicknow-make-choose-account-scrollable' into main 2023-11-02 09:43:37 -07:00
Paul Frazee b782cd0d06 Fix max-height issues 2023-11-02 09:42:29 -07:00
Paul Frazee 4e5a99d4d3 Merge branch 'make-choose-account-scrollable' of https://github.com/flicknow/social-app into flicknow-make-choose-account-scrollable 2023-11-02 09:34:49 -07:00
Spandan Tripathy d7744c2590 Update build.md (#1785) 2023-11-02 09:27:31 -07:00
Paul Frazee 29bfed78f7 Merge branch 'main' of github.com:bluesky-social/social-app into main 2023-11-01 21:14:47 -07:00
Paul Frazee 232e90c0a0 1.56 2023-11-01 21:14:39 -07:00
Paul Frazee 59271663b5 Store saved feeds on the root store so we can load on init (#1793) 2023-11-01 18:45:59 -07:00
flicknow 5f135aa021 Make choose account form scrollable 2023-11-01 20:55:06 -04:00
Paul Frazee f57a8cf8ba Lists updates: curate lists and blocklists (#1689)
* Add lists screen

* Update Lists screen and List create/edit modal to support curate lists

* Rework the ProfileList screen and add curatelist support

* More ProfileList progress

* Update list modals

* Rename mutelists to modlists

* Layout updates/fixes

* More layout fixes

* Modal fixes

* List list screen updates

* Update feed page to give more info

* Layout fixes to ListAddUser modal

* Layout fixes to FlatList and Feed on desktop

* Layout fix to LoadLatestBtn on Web

* Handle did resolution before showing the ProfileList screen

* Rename the CustomFeed routes to ProfileFeed for consistency

* Fix layout issues with the pager and feeds

* Factor out some common code

* Fix UIs for mobile

* Fix user list rendering

* Fix: dont bubble custom feed errors in the merge feed

* Refactor feed models to reduce usage of the SavedFeeds model

* Replace CustomFeedModel with FeedSourceModel which abstracts feed-generators and lists

* Add the ability to pin lists

* Add pinned lists to mobile

* Remove dead code

* Rework the ProfileScreenHeader to create more real-estate for action buttons

* Improve layout behavior on web mobile breakpoints

* Refactor feed & list pages to use new Tabs layout component

* Refactor to ProfileSubpageHeader

* Implement modlist block and mute

* Switch to new api and just modify state on modlist actions

* Fix some UI overflows

* Fix: dont show edit buttons on lists you dont own

* Fix alignment issue on long titles

* Improve loading and error states for feeds & lists

* Update list dropdown icons for ios

* Fetch feed display names in the mergefeed

* Improve rendering off offline feeds in the feed-listing page

* Update Feeds listing UI to react to changes in saved/pinned state

* Refresh list and feed on posts tab press

* Fix pinned feed ordering UI

* Fixes to list pinning

* Remove view=simple qp

* Add list to feed tuners

* Render richtext

* Add list href

* Add 'view avatar'

* Remove unused import

* Fix missing import

* Correctly reflect block by list state

* Replace the <Tabs> component with the more effective <PagerWithHeader> component

* Improve the responsiveness of the PagerWithHeader

* Fix visual jank in the feed loading state

* Improve performance of the PagerWithHeader

* Fix a case that would cause the header to animate too aggressively

* Add the ability to scroll to top by tapping the selected tab

* Fix unit test runner

* Update modlists test

* Add curatelist tests

* Fix: remove link behavior in ListAddUser modal

* Fix some layout jank in the PagerWithHeader on iOS

* Simplify ListItems header rendering

* Wait for the appview to recognize the list before proceeding with list creation

* Fix glitch in the onPageSelecting index of the Pager

* Fix until()

* Copy fix

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2023-11-01 16:15:40 -07:00
423 changed files with 36501 additions and 19813 deletions
+2
View File
@@ -1 +1,3 @@
SENTRY_AUTH_TOKEN=
EXPO_PUBLIC_LOG_LEVEL=debug
EXPO_PUBLIC_LOG_DEBUG=
+208
View File
@@ -0,0 +1,208 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Curate lists', () => {
beforeAll(async () => {
await createServer('?users&follows&posts')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
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()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editNameInput')).typeText('Good Ppl')
await element(by.id('editDescriptionInput')).typeText('They good')
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await element(by.text('About')).tap()
await expect(element(by.id('headerTitle'))).toHaveText('Good Ppl')
await expect(element(by.id('listDescription'))).toHaveText('They good')
})
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 expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editNameInput')).clearText()
await element(by.id('editNameInput')).typeText('Bad Ppl')
await element(by.id('editDescriptionInput')).clearText()
await element(by.id('editDescriptionInput')).typeText('They bad')
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
await expect(element(by.id('listDescription'))).toHaveText('They bad')
// have to wait for the toast to clear
await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Remove description via the edit curatelist modal', async () => {
await element(by.id('headerDropdownBtn')).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()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('listDescription'))).not.toBeVisible()
// have to wait for the toast to clear
await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
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 expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Library')).tap()
await sleep(3e3)
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarImage'))).toExist()
// have to wait for the toast to clear
await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
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 expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Remove')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarFallback'))).toExist()
// have to wait for the toast to clear
await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Delete the curatelist', async () => {
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Delete List')).tap()
await element(by.id('confirmBtn')).tap()
await expect(element(by.id('listsEmpty'))).toBeVisible()
})
it('Create a new curatelist', async () => {
await element(by.id('newUserListBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editNameInput')).typeText('Good Ppl')
await element(by.id('editDescriptionInput')).typeText('They good')
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await element(by.text('About')).tap()
await expect(element(by.id('headerTitle'))).toHaveText('Good Ppl')
await expect(element(by.id('listDescription'))).toHaveText('They good')
})
it('Adds users on curatelists from the list', async () => {
await element(by.text('About')).tap()
await element(by.id('addUserBtn')).tap()
await expect(element(by.id('listAddUserModal'))).toBeVisible()
await waitFor(element(by.id('user-bob.test-addBtn')))
.toBeVisible()
.withTimeout(5000)
await element(by.id('user-bob.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await expect(element(by.id('listAddUserModal'))).not.toBeVisible()
await expect(element(by.id('user-bob.test'))).toBeVisible()
})
it('Shows posts by the users in the list', async () => {
await element(by.text('Posts')).tap()
await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible()
})
it('Pins the list', async () => {
await element(by.id('pinBtn')).tap()
await element(by.id('e2eGotoHome')).tap()
await element(by.id('homeScreenFeedTabs-Good Ppl')).tap()
await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible()
await element(by.id('bottomBarFeedsBtn')).tap()
await element(by.id('saved-feed-Good Ppl')).tap()
await expect(element(by.id('feedItem-by-bob.test'))).toBeVisible()
await element(by.id('unpinBtn')).tap()
await element(by.id('bottomBarHomeBtn')).tap()
await expect(
element(by.id('homeScreenFeedTabs-Good Ppl')),
).not.toBeVisible()
await element(by.id('e2eGotoLists')).tap()
await element(by.id('list-Good Ppl')).tap()
})
it('Removes users on curatelists from the list', async () => {
await element(by.text('About')).tap()
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 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('list-Good Ppl')).tap()
})
it('Adds and removes users on curatelists from the profile', async () => {
await element(by.id('bottomBarSearchBtn')).tap()
await element(by.id('searchTextInput')).typeText('bob')
await element(by.id('searchAutoCompleteResult-bob.test')).tap()
await expect(element(by.id('profileView'))).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 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 expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
it('Can report a user list', async () => {
await element(by.id('e2eGotoSettings')).tap()
await element(by.id('signOutBtn')).tap()
await loginAsBob()
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('list-Good Ppl')).tap()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Report List')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await expect(element(by.text('Report List'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonRude'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
})
})
@@ -2,7 +2,7 @@
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Mute lists', () => {
describe('Mod lists', () => {
beforeAll(async () => {
await createServer('?users&follows&labels')
await openApp({
@@ -10,11 +10,11 @@ describe('Mute lists', () => {
})
})
it('Login and view my mutelists', async () => {
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('mutelistsBtn')).tap()
await element(by.id('moderationlistsBtn')).tap()
await expect(element(by.id('list-Muted Users'))).toBeVisible()
await element(by.id('list-Muted Users')).tap()
await expect(
@@ -22,101 +22,128 @@ describe('Mute lists', () => {
).toBeVisible()
})
it('Toggle subscription', async () => {
await element(by.id('unsubscribeListBtn')).tap()
await element(by.id('subscribeListBtn')).tap()
it('Toggle mute subscription', async () => {
await element(by.id('unmuteBtn')).tap()
await element(by.id('subscribeBtn')).tap()
await element(by.text('Mute accounts')).tap()
await element(by.id('confirmBtn')).tap()
})
it('Edit display name and description via the edit mutelist modal', async () => {
await element(by.id('editListBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
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 expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editNameInput')).clearText()
await element(by.id('editNameInput')).typeText('Bad Ppl')
await element(by.id('editDescriptionInput')).clearText()
await element(by.id('editDescriptionInput')).typeText('They bad')
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
await expect(element(by.id('listDescription'))).toHaveText('They bad')
// have to wait for the toast to clear
await waitFor(element(by.id('editListBtn')))
await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Remove description via the edit mutelist modal', async () => {
await element(by.id('editListBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
it('Remove description via the edit modlist modal', async () => {
await element(by.id('headerDropdownBtn')).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()
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('listDescription'))).not.toBeVisible()
// have to wait for the toast to clear
await waitFor(element(by.id('editListBtn')))
await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Set avi via the edit mutelist modal', async () => {
it('Set avi via the edit modlist modal', async () => {
await expect(element(by.id('userAvatarFallback'))).toExist()
await element(by.id('editListBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
await element(by.id('headerDropdownBtn')).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()
await sleep(3e3)
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarImage'))).toExist()
// have to wait for the toast to clear
await waitFor(element(by.id('editListBtn')))
await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Remove avi via the edit mutelist modal', async () => {
it('Remove avi via the edit modlist modal', async () => {
await expect(element(by.id('userAvatarImage'))).toExist()
await element(by.id('editListBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
await element(by.id('headerDropdownBtn')).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()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarFallback'))).toExist()
// have to wait for the toast to clear
await waitFor(element(by.id('editListBtn')))
await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Delete the mutelist', async () => {
await element(by.id('deleteListBtn')).tap()
it('Delete the modlist', async () => {
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Delete List')).tap()
await element(by.id('confirmBtn')).tap()
await expect(element(by.id('emptyMuteLists'))).toBeVisible()
await expect(element(by.id('listsEmpty'))).toBeVisible()
})
it('Create a new mutelist', async () => {
await element(by.id('emptyMuteLists-button')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
it('Create a new modlist', async () => {
await element(by.id('newModListBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('editNameInput')).typeText('Bad Ppl')
await element(by.id('editDescriptionInput')).typeText('They bad')
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('headerTitle'))).toHaveText('Bad Ppl')
await expect(element(by.id('listDescription'))).toHaveText('They bad')
// have to wait for the toast to clear
await waitFor(element(by.id('editListBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Shows the mutelist on my profile', async () => {
it('Adds and removes users on modlists from the list', async () => {
await element(by.id('addUserBtn')).tap()
await expect(element(by.id('listAddUserModal'))).toBeVisible()
await waitFor(element(by.id('user-warn-posts.test-addBtn')))
.toBeVisible()
.withTimeout(5000)
await element(by.id('user-warn-posts.test-addBtn')).tap()
await element(by.id('doneBtn')).tap()
await expect(element(by.id('listAddUserModal'))).not.toBeVisible()
await element(by.id('listItems-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
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 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('list-Bad Ppl')).tap()
})
it('Adds and removes users on mutelists', async () => {
it('Adds and removes users on modlists from the profile', async () => {
await element(by.id('bottomBarSearchBtn')).tap()
await element(by.id('searchTextInput')).typeText('bob')
await element(by.id('searchAutoCompleteResult-bob.test')).tap()
@@ -124,17 +151,17 @@ describe('Mute lists', () => {
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Add to Lists')).tap()
await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible()
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
await element(by.id('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
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('listAddRemoveUserModal'))).toBeVisible()
await expect(element(by.id('userAddRemoveListsModal'))).toBeVisible()
await element(by.id('toggleBtn-Bad Ppl')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
await expect(element(by.id('userAddRemoveListsModal'))).not.toBeVisible()
})
it('Can report a mute list', async () => {
@@ -147,7 +174,8 @@ describe('Mute lists', () => {
await element(by.id('selector')).swipe('left')
await element(by.id('selector-3')).tap()
await element(by.id('list-Bad Ppl')).tap()
await element(by.id('reportListBtn')).tap()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Report List')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await expect(element(by.text('Report List'))).toBeVisible()
await element(
-7
View File
@@ -1,10 +1,3 @@
jest.mock('sentry-expo', () => ({
init: () => jest.fn(),
Native: {
ReactNativeTracing: jest.fn().mockImplementation(() => ({
start: jest.fn(),
stop: jest.fn(),
})),
ReactNavigationInstrumentation: jest.fn(),
},
}))
@@ -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 = '4'
/**
* 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.55.0',
runtimeVersion: {
policy: 'appVersion',
},
@@ -19,7 +48,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
ios: {
buildNumber: '1',
buildNumber: IOS_BUILD_NUMBER,
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
config: {
@@ -43,7 +72,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
android: {
versionCode: 44,
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}`,
},
},
],
+2
View File
@@ -37,6 +37,7 @@ module.exports = function (api) {
{
alias: {
// This needs to be mirrored in tsconfig.json
'#': './src',
lib: './src/lib',
platform: './src/platform',
state: './src/state',
@@ -44,6 +45,7 @@ module.exports = function (api) {
},
},
],
'macros',
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
],
env: {
+2 -1
View File
@@ -181,8 +181,9 @@ func serve(cctx *cli.Context) error {
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/mute-lists", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
e.GET("/moderation/muted-accounts", server.WebGeneric)
e.GET("/moderation/blocked-accounts", server.WebGeneric)
e.GET("/settings", server.WebGeneric)
+2 -2
View File
@@ -176,8 +176,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
{% block html_head_extra -%}{%- endblock %}
<meta name="application-name" name="Bluesky">
<meta name="generator" name="bskyweb">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
</head>
<body>
{%- block body_all %}
+14 -14
View File
@@ -2,7 +2,7 @@
## App Build
- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
- Set up your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
- Setup your environment [for e2e testing using detox](https://wix.github.io/Detox/docs/introduction/getting-started):
- yarn global add detox-cli
- brew tap wix/brew
@@ -20,7 +20,7 @@
- iOS: `yarn ios`
- Android: `yarn android`
- Web: `yarn web`
- If you are cloning or forking this repo as an open source developer, please check the tips below as well
- If you are cloning or forking this repo as an open-source developer, please check the tips below as well
- Run e2e tests
- Start in various console tabs:
- `yarn e2e:mock-server`
@@ -28,14 +28,14 @@
- Run once: `yarn e2e:build`
- Each test run: `yarn e2e:run`
- Tips
- Make sure to copy the `.env.example` to `.env` and fill in any necessary tokens. (The Sentry token is NOT required; see instructions below if you want to enable Sentry.)
- To run on device, add `--device` to the command (e.g. `yarn android --device`). To build in production mode (slower build, faster app), also add `--variant release`.
- If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` as well as `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
- Copy the `.env.example` to `.env` and fill in any necessary tokens. (The Sentry token is NOT required; see instructions below if you want to enable Sentry.)
- To run on the device, add `--device` to the command (e.g. `yarn android --device`). To build in production mode (slower build, faster app), also add `--variant release`.
- If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` and `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
- `npx react-native info` Checks what has been installed.
- If the android simulator frequently hangs or is very sluggish, [bump its memory limit](https://stackoverflow.com/a/40068396)
- The android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
- If the Android simulator frequently hangs or is very sluggish, [bump its memory limit](https://stackoverflow.com/a/40068396)
- The Android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
- For instance, the locally-hosted dev-wallet will need `adb reverse tcp:3001 tcp:3001`
- For some reason, the typescript compiler chokes on platform-specific files (e.g. `foo.native.ts`) but only when compiling for Web thus far. Therefore we always have one version of the file which doesn't use a platform specifier, and that should be the Web version. ([More info](https://stackoverflow.com/questions/44001050/platform-specific-import-component-in-react-native-with-typescript).)
- For some reason, the typescript compiler chokes on platform-specific files (e.g. `foo.native.ts`) but only when compiling for Web thus far. Therefore we always have one version of the file that doesn't use a platform specifier, and that should be the Web version. ([More info](https://stackoverflow.com/questions/44001050/platform-specific-import-component-in-react-native-with-typescript).)
### Adding Sentry
@@ -81,24 +81,24 @@ To open the [Developer Menu](https://docs.expo.dev/debugging/tools/#developer-me
- Android Device: Shake the device vertically, or if your device is connected via USB, run adb shell input keyevent 82 in your terminal
- Android Emulator: Either press Cmd ⌘ + m or Ctrl + m or run adb shell input keyevent 82 in your terminal
- iOS Device: Shake the device, or touch 3 fingers to the screen
- iOS Simulator: Press Ctrl + Cmd ⌘ + z on a Mac in the emulator to simulate the shake gesture, or press Cmd ⌘ + d
- iOS Simulator: Press Ctrl + Cmd ⌘ + z on a Mac in the emulator to simulate the shake gesture or press Cmd ⌘ + d
### Running E2E Tests
- Make sure you've setup your environment following above
- Make sure you've set your environment following the above
- Make sure Metro and the dev server are running
- Run `yarn e2e`
- Find the artifacts in the `artifact` folder
### Polyfills
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes:
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes:
- TextEncoder / TextDecoder
### Sentry sourcemaps
Sourcemaps should automatically be updated when a signed build is created using `eas build` and published using `eas submit` due to the postPublish hook setup in `app.json`. However, if an update is created and published OTA using `eas update`, we need to the take the following steps to upload sourcemaps to Sentry:
Sourcemaps should automatically be updated when a signed build is created using `eas build` and published using `eas submit` due to the postPublish hook setup in `app.json`. However, if an update is created and published OTA using `eas update`, we need to take the following steps to upload sourcemaps to Sentry:
- Run eas update. This will generate a dist folder in your project root, which contains your JavaScript bundles and source maps. This command will also output the 'Android update ID' and 'iOS update ID' that we'll need in the next step.
- Copy or rename the bundle names in the `dist/bundles` folder to match `index.android.bundle` (Android) or `main.jsbundle` (iOS).
@@ -122,6 +122,6 @@ dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`
### OTA updates
To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. ALl the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/)
To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. All the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/)
The clients which can receive an OTA update is governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update-to-update. If we are manually setting `runtimeVersion`, we should increment the version each time native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/)
The clients which can receive an OTA update are governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update to update. If we are manually setting `runtimeVersion`, we should increment the version each time the native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/)
+113
View File
@@ -0,0 +1,113 @@
# Internationalization
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please open a PR or issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app)
## Tools
We are using Lingui to manage translations. You can find the documentation [here](https://lingui.dev/).
### Adding new strings
When adding a new string, do it as follows:
```jsx
// Before
import { Text } from "react-native";
<Text>Hello World</Text>
```
```jsx
// After
import { Text } from "react-native";
import { Trans } from "@lingui/macro";
<Text><Trans>Hello World</Trans></Text>
```
The `<Trans>` macro will extract the string and add it to the catalog. It is not really a component, but a macro. Further reading [here](https://lingui.dev/ref/macro.html)
However sometimes you will run into this case:
```jsx
// Before
import { Text } from "react-native";
const text = "Hello World";
<Text accessibilityLabel="Label is here">{text}</Text>
```
In this case, you cannot use the `useLingui()` hook:
```jsx
import { msg } from "@lingui/macro";
import { useLingui } from "@lingui/react";
const { _ } = useLingui();
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
```
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
```jsx
import { t } from "@lingui/macro";
const text = t`Hello World`;
```
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
We can then run `yarn intl:compile` to update the translation files in `src/locale/locales/{locale}/messages.js`. This will add the new string to the translation files.
The configuration for translations is defined in `lingui.config.js`
So the workflow is as follows:
1. Wrap messages in Trans macro
2. Run `yarn intl:extract` command to generate message catalogs
3. Translate message catalogs (send them to translators usually)
4. Run `yarn intl:compile` to create runtime catalogs
5. Load runtime catalog
6. Enjoy translated app!
### Common pitfalls
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
```jsx
import { msg } from "@lingui/macro";
import { i18n } from "@lingui/core";
const welcomeMessage = msg`Welcome!`;
// ❌ Bad! This code won't work
export function Welcome() {
const buggyWelcome = useMemo(() => {
return i18n._(welcomeMessage);
}, []);
return <div>{buggyWelcome}</div>;
}
// ❌ Bad! This code won't work either because the reference to i18n does not change
export function Welcome() {
const { i18n } = useLingui();
const buggyWelcome = useMemo(() => {
return i18n._(welcomeMessage);
}, [i18n]);
return <div>{buggyWelcome}</div>;
}
// ✅ Good! `useMemo` has i18n context in the dependency
export function Welcome() {
const linguiCtx = useLingui();
const welcome = useMemo(() => {
return linguiCtx.i18n._(welcomeMessage);
}, [linguiCtx]);
return <div>{welcome}</div>;
}
// 🤩 Better! `useMemo` consumes the `_` function from the Lingui context
export function Welcome() {
const { _ } = useLingui();
const welcome = useMemo(() => {
return _(welcomeMessage);
}, [_]);
return <div>{welcome}</div>;
}
```
+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": {
+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', () =>
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
locales: ['en', 'cs', 'fr', 'hi', 'es'],
catalogs: [
{
path: '<rootDir>/src/locale/locales/{locale}/messages',
include: ['src'],
},
],
format: 'po',
}
+26 -16
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.55.0",
"version": "1.57.0",
"private": true,
"scripts": {
"prepare": "is-ci || husky install",
@@ -13,25 +13,26 @@
"start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
"test": "jest --forceExit --testTimeout=20000 --bail",
"test-watch": "jest --watchAll",
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "jest --coverage",
"test": "NODE_ENV=test jest --forceExit --testTimeout=20000 --bail",
"test-watch": "NODE_ENV=test jest --watchAll",
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage",
"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",
"perf:test": "maestro test",
"perf:test:run": "maestro test __e2e__/maestro/scroll.yaml",
"perf:test:measure": "flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:results": "flashlight report .perf/results.json",
"perf:measure": "flashlight measure",
"build:apk": "eas build -p android --profile dev-android-apk"
"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",
"intl:extract": "lingui extract",
"intl:compile": "lingui compile"
},
"dependencies": {
"@atproto/api": "^0.6.21",
"@atproto/api": "^0.6.23",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1",
@@ -42,6 +43,7 @@
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.0",
"@gorhom/bottom-sheet": "^4.5.1",
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.6.4",
"@miblanchard/react-native-slider": "^2.3.1",
"@react-native-async-storage/async-storage": "1.18.2",
@@ -60,7 +62,7 @@
"@segment/analytics-react-native": "^2.10.1",
"@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.10.0",
"@tanstack/react-query": "^4.33.0",
"@tanstack/react-query": "^5.8.1",
"@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220",
"@tiptap/extension-hard-break": "^2.0.3",
@@ -80,6 +82,7 @@
"babel-plugin-transform-remove-console": "^6.9.4",
"base64-js": "^1.5.1",
"bcp-47-match": "^2.0.3",
"date-fns": "^2.30.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
"eventemitter3": "^5.0.1",
@@ -118,6 +121,7 @@
"mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0",
"mobx-utils": "^6.0.6",
"nanoid": "^5.0.2",
"normalize-url": "^8.0.0",
"patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0",
@@ -128,7 +132,6 @@
"react-dom": "^18.2.0",
"react-native": "0.72.5",
"react-native-appstate-hook": "^1.0.6",
"react-native-draggable-flatlist": "^4.0.1",
"react-native-drawer-layout": "^3.2.0",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.12.1",
@@ -157,15 +160,18 @@
"sentry-expo": "~7.0.1",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
"use-deep-compare": "^1.1.0",
"zeego": "^1.6.2",
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.2.5",
"@babel/core": "^7.20.0",
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
"@did-plc/server": "^0.0.1",
"@lingui/cli": "^4.5.0",
"@lingui/macro": "^4.5.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@react-native-community/eslint-config": "^3.0.0",
"@testing-library/jest-native": "^5.4.1",
@@ -190,6 +196,7 @@
"@typescript-eslint/parser": "^5.48.2",
"babel-jest": "^29.4.2",
"babel-loader": "^9.1.2",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-module-resolver": "^5.0.0",
"babel-plugin-react-native-web": "^0.18.12",
"detox": "^20.13.0",
@@ -236,8 +243,11 @@
"json",
"node"
],
"transform": {
"\\.[jt]sx?$": "babel-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
],
"modulePathIgnorePatterns": [
"__tests__/.*/__mocks__",
+44
View File
@@ -0,0 +1,44 @@
diff --git a/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js b/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js
index 48a1409..ef185c9 100644
--- a/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js
+++ b/node_modules/metro/src/ModuleGraph/worker/JsFileWrapping.js
@@ -70,14 +70,19 @@ function wrapModule(
importDefaultName,
importAllName,
dependencyMapName,
- globalPrefix
+ globalPrefix,
+ moduleFactoryName
) {
const params = buildParameters(
importDefaultName,
importAllName,
dependencyMapName
);
- const factory = functionFromProgram(fileAst.program, params);
+ const factory = functionFromProgram(
+ fileAst.program,
+ params,
+ moduleFactoryName
+ );
const def = t.callExpression(t.identifier(`${globalPrefix}__d`), [factory]);
const ast = t.file(t.program([t.expressionStatement(def)]));
const requireName = renameRequires(ast);
@@ -107,7 +112,16 @@ function wrapJson(source, globalPrefix) {
"});",
].join("\n");
}
-function functionFromProgram(program, parameters) {
+const JS_INVALID_IDENT_RE = /[^a-zA-Z0-9$_]/g;
+function functionFromProgram(program, parameters, moduleFactoryName) {
+ let identifier;
+ if (typeof moduleFactoryName === "string" && moduleFactoryName !== "") {
+ // Keep the name readable so it shows up in profiler traces.
+ // Add an unlikely suffix to avoid collisions with the module code.
+ identifier = t.identifier(
+ `${moduleFactoryName.replace(JS_INVALID_IDENT_RE, "_")}__module_factory__`
+ );
+ }
return t.functionExpression(
undefined,
parameters.map(makeIdentifier),
@@ -0,0 +1,41 @@
diff --git a/node_modules/metro-transform-worker/src/index.js b/node_modules/metro-transform-worker/src/index.js
index 27d4cb3..fd71f47 100644
--- a/node_modules/metro-transform-worker/src/index.js
+++ b/node_modules/metro-transform-worker/src/index.js
@@ -190,6 +190,10 @@ async function transformJS(file, { config, options, projectRoot }) {
let dependencyMapName = "";
let dependencies;
let wrappedAst;
+ const minify =
+ options.minify &&
+ options.unstable_transformProfile !== "hermes-canary" &&
+ options.unstable_transformProfile !== "hermes-stable";
// If the module to transform is a script (meaning that is not part of the
// dependency graph and it code will just be prepended to the bundle modules),
@@ -229,19 +233,20 @@ async function transformJS(file, { config, options, projectRoot }) {
if (config.unstable_disableModuleWrapping === true) {
wrappedAst = ast;
} else {
+ let moduleFactoryName;
+ if (options.dev && !minify) {
+ moduleFactoryName = file.filename;
+ }
({ ast: wrappedAst } = JsFileWrapping.wrapModule(
ast,
importDefault,
importAll,
dependencyMapName,
- config.globalPrefix
+ config.globalPrefix,
+ moduleFactoryName
));
}
}
- const minify =
- options.minify &&
- options.unstable_transformProfile !== "hermes-canary" &&
- options.unstable_transformProfile !== "hermes-stable";
const reserved = [];
if (config.unstable_dependencyMapReservedName != null) {
reserved.push(config.unstable_dependencyMapReservedName);
+98 -30
View File
@@ -5,61 +5,129 @@ import React, {useState, useEffect} from 'react'
import {RootSiblingParent} from 'react-native-root-siblings'
import * as SplashScreen from 'expo-splash-screen'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {observer} from 'mobx-react-lite'
import {QueryClientProvider} from '@tanstack/react-query'
import {enableFreeze} from 'react-native-screens'
import 'view/icons'
import {withSentry} from 'lib/sentry'
import {init as initPersistedState} from '#/state/persisted'
import {init as initReminders} from '#/state/shell/reminders'
import {listenSessionDropped} from './state/events'
import {useColorMode} from 'state/shell'
import {ThemeProvider} from 'lib/ThemeContext'
import {s} from 'lib/styles'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from 'view/shell'
import * as notifications from 'lib/notifications/notifications'
import * as analytics from 'lib/analytics/analytics'
import * as Toast from 'view/com/util/Toast'
import {queryClient} from 'lib/react-query'
import {TestCtrls} from 'view/com/testing/TestCtrls'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
enableFreeze(true)
SplashScreen.preventAutoHideAsync()
const App = observer(function AppImpl() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
function InnerApp() {
const colorMode = useColorMode()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
// init
useEffect(() => {
setupState().then(store => {
setRootStore(store)
analytics.init(store)
notifications.init(store)
store.onSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
initReminders()
analytics.init()
notifications.init(queryClient)
listenSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
}, [])
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
// show nothing prior to init
if (!rootStore) {
if (isInitialLoad) {
// TODO add a loading state
return null
}
/*
* Session and initial state should be loaded prior to rendering below.
*/
return (
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
)
}
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])
if (!isReady) {
return null
}
/*
* NOTE: only nothing here can depend on other data or session state, since
* that is set up in the InnerApp component above.
*/
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<I18nProvider>
<InnerApp />
</I18nProvider>
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
)
})
}
export default withSentry(App)
export default App
+92 -25
View File
@@ -1,54 +1,121 @@
import 'lib/sentry' // must be near top
import React, {useState, useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {QueryClientProvider} from '@tanstack/react-query'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {RootSiblingParent} from 'react-native-root-siblings'
import {enableFreeze} from 'react-native-screens'
import 'view/icons'
import {init as initPersistedState} from '#/state/persisted'
import {init as initReminders} from '#/state/shell/reminders'
import {useColorMode} from 'state/shell'
import * as analytics from 'lib/analytics/analytics'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from 'view/shell/index'
import {ToastContainer} from 'view/com/util/Toast.web'
import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
const App = observer(function AppImpl() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
enableFreeze(true)
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
// init
useEffect(() => {
setupState().then(store => {
setRootStore(store)
analytics.init(store)
})
}, [])
initReminders()
analytics.init()
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
// show nothing prior to init
if (!rootStore) {
if (isInitialLoad) {
// TODO add a loading state
return null
}
/*
* Session and initial state should be loaded prior to rendering below.
*/
return (
<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>
)
}
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])
if (!isReady) {
return null
}
/*
* NOTE: only nothing here can depend on other data or session state, since
* that is set up in the InnerApp component above.
*/
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<ToastContainer />
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<I18nProvider>
<InnerApp />
</I18nProvider>
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
)
})
}
export default App
+50 -52
View File
@@ -1,7 +1,6 @@
import * as React from 'react'
import {StyleSheet} from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
import {observer} from 'mobx-react-lite'
import {
NavigationContainer,
createNavigationContainerRef,
@@ -10,7 +9,6 @@ import {
DefaultTheme,
DarkTheme,
} from '@react-navigation/native'
import {createNativeStackNavigator} from '@react-navigation/native-stack'
import {
BottomTabBarProps,
createBottomTabNavigator,
@@ -33,26 +31,26 @@ import {isNative} from 'platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from './state'
import {getRoutingInstrumentation} from 'lib/sentry'
import {bskyTitle} from 'lib/strings/headings'
import {JSX} from 'react/jsx-runtime'
import {timeout} from 'lib/async/timeout'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {HomeScreen} from './view/screens/Home'
import {SearchScreen} from './view/screens/Search'
import {FeedsScreen} from './view/screens/Feeds'
import {NotificationsScreen} from './view/screens/Notifications'
import {ListsScreen} from './view/screens/Lists'
import {ModerationScreen} from './view/screens/Moderation'
import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound'
import {SettingsScreen} from './view/screens/Settings'
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
import {ProfileScreen} from './view/screens/Profile'
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
import {CustomFeedScreen} from './view/screens/CustomFeed'
import {CustomFeedLikedByScreen} from './view/screens/CustomFeedLikedBy'
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
import {ProfileListScreen} from './view/screens/ProfileList'
import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
@@ -70,16 +68,18 @@ import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigator<FeedsTabNavigatorParams>()
const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigatorWithAuth<FeedsTabNavigatorParams>()
const NotificationsTab =
createNativeStackNavigator<NotificationsTabNavigatorParams>()
const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>()
const Flat = createNativeStackNavigator<FlatNavigatorParams>()
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
const MyProfileTab =
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
/**
@@ -96,34 +96,39 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
options={{title: title('Not Found')}}
/>
<Stack.Screen
name="Moderation"
getComponent={() => ModerationScreen}
options={{title: title('Moderation')}}
name="Lists"
component={ListsScreen}
options={{title: title('Lists'), requireAuth: true}}
/>
<Stack.Screen
name="ModerationMuteLists"
getComponent={() => ModerationMuteListsScreen}
options={{title: title('Mute Lists')}}
name="Moderation"
getComponent={() => ModerationScreen}
options={{title: title('Moderation'), requireAuth: true}}
/>
<Stack.Screen
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
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"
@@ -150,7 +155,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="ProfileList"
getComponent={() => ProfileListScreen}
options={{title: title('Mute List')}}
options={{title: title('List'), requireAuth: true}}
/>
<Stack.Screen
name="PostThread"
@@ -168,24 +173,24 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/>
<Stack.Screen
name="CustomFeed"
getComponent={() => CustomFeedScreen}
name="ProfileFeed"
getComponent={() => ProfileFeedScreen}
options={{title: title('Feed')}}
/>
<Stack.Screen
name="CustomFeedLikedBy"
getComponent={() => CustomFeedLikedByScreen}
name="ProfileFeedLikedBy"
getComponent={() => ProfileFeedLikedByScreen}
options={{title: title('Liked by')}}
/>
<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"
@@ -215,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}}
/>
</>
)
@@ -335,15 +340,15 @@ function NotificationsTabNavigator() {
<NotificationsTab.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{requireAuth: true}}
/>
{commonScreens(NotificationsTab as typeof HomeTab)}
</NotificationsTab.Navigator>
)
}
const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
function MyProfileTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
const store = useStores()
return (
<MyProfileTab.Navigator
screenOptions={{
@@ -354,28 +359,27 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
contentStyle,
}}>
<MyProfileTab.Screen
name="MyProfile"
// @ts-ignore // TODO: fix this broken type in ProfileScreen
name="MyProfile"
getComponent={() => ProfileScreen}
initialParams={{
name: store.me.did,
name: 'me',
}}
/>
{commonScreens(MyProfileTab as typeof HomeTab)}
</MyProfileTab.Navigator>
)
})
}
/**
* The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack.
*/
const FlatNavigator = observer(function FlatNavigatorImpl() {
const FlatNavigator = () => {
const pal = usePalette('default')
const store = useStores()
const unreadCountLabel = store.me.notifications.unreadCountLabel
const numUnread = useUnreadNotifications()
const title = (page: string) => bskyTitle(page, unreadCountLabel)
const title = (page: string) => bskyTitle(page, numUnread)
return (
<Flat.Navigator
screenOptions={{
@@ -403,12 +407,12 @@ const FlatNavigator = observer(function FlatNavigatorImpl() {
<Flat.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{title: title('Notifications')}}
options={{title: title('Notifications'), requireAuth: true}}
/>
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
)
})
}
/**
* The RoutesContainer should wrap all components which need access
@@ -473,12 +477,6 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
)
console.log(`Time to first paint: ${initMs} ms`)
logModuleInitTrace()
// Register the navigation container with the Sentry instrumentation (only works on native)
if (isNative) {
const routingInstrumentation = getRoutingInstrumentation()
routingInstrumentation.registerNavigationContainer(navigationRef)
}
}}>
{children}
</NavigationContainer>
+9
View File
@@ -0,0 +1,9 @@
export const IS_TEST = process.env.NODE_ENV === 'test'
export const IS_DEV = __DEV__
export const IS_PROD = !IS_DEV
export const LOG_DEBUG = process.env.EXPO_PUBLIC_LOG_DEBUG || ''
export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as
| 'debug'
| 'info'
| 'warn'
| 'error'
+57 -42
View File
@@ -1,15 +1,26 @@
import React from 'react'
import {AppState, AppStateStatus} from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
createClient,
AnalyticsProvider,
useAnalytics as useAnalyticsOrig,
ClientMethods,
} from '@segment/analytics-react-native'
import {RootStoreModel, AppInfo} from 'state/models/root-store'
import {useStores} from 'state/models/root-store'
import {z} from 'zod'
import {useSession} from '#/state/session'
import {sha256} from 'js-sha256'
import {ScreenEvent, TrackEvent} from './types'
import {logger} from '#/logger'
import {listenSessionLoaded} from '#/state/events'
export const appInfo = z.object({
build: z.string().optional(),
name: z.string().optional(),
namespace: z.string().optional(),
version: z.string().optional(),
})
export type AppInfo = z.infer<typeof appInfo>
const segmentClient = createClient({
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
@@ -20,10 +31,10 @@ const segmentClient = createClient({
export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent
export function useAnalytics() {
const store = useStores()
const {hasSession} = useSession()
const methods: ClientMethods = useAnalyticsOrig()
return React.useMemo(() => {
if (store.session.hasSession) {
if (hasSession) {
return {
screen: methods.screen as ScreenEvent, // ScreenEvents defines all the possible screen names
track: methods.track as TrackEvent, // TrackEvents defines all the possible track events and their properties
@@ -44,21 +55,18 @@ export function useAnalytics() {
alias: () => Promise<void>,
reset: () => Promise<void>,
}
}, [store, methods])
}, [hasSession, methods])
}
export function init(store: RootStoreModel) {
store.onSessionLoaded(() => {
const sess = store.session.currentSession
if (sess) {
if (sess.did) {
const did_hashed = sha256(sess.did)
segmentClient.identify(did_hashed, {did_hashed})
store.log.debug('Ping w/hash')
} else {
store.log.debug('Ping w/o hash')
segmentClient.identify()
}
export function init() {
listenSessionLoaded(account => {
if (account.did) {
const did_hashed = sha256(account.did)
segmentClient.identify(did_hashed, {did_hashed})
logger.debug('Ping w/hash')
} else {
logger.debug('Ping w/o hash')
segmentClient.identify()
}
})
@@ -66,46 +74,40 @@ export function init(store: RootStoreModel) {
// this is a copy of segment's own lifecycle event tracking
// we handle it manually to ensure that it never fires while the app is backgrounded
// -prf
segmentClient.isReady.onChange(() => {
segmentClient.isReady.onChange(async () => {
if (AppState.currentState !== 'active') {
store.log.debug('Prevented a metrics ping while the app was backgrounded')
logger.debug('Prevented a metrics ping while the app was backgrounded')
return
}
const context = segmentClient.context.get()
if (typeof context?.app === 'undefined') {
store.log.debug('Aborted metrics ping due to unavailable context')
logger.debug('Aborted metrics ping due to unavailable context')
return
}
const oldAppInfo = store.appInfo
const oldAppInfo = await readAppInfo()
const newAppInfo = context.app as AppInfo
store.setAppInfo(newAppInfo)
store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
writeAppInfo(newAppInfo)
logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
if (typeof oldAppInfo === 'undefined') {
if (store.session.hasSession) {
segmentClient.track('Application Installed', {
version: newAppInfo.version,
build: newAppInfo.build,
})
}
} else if (newAppInfo.version !== oldAppInfo.version) {
if (store.session.hasSession) {
segmentClient.track('Application Updated', {
version: newAppInfo.version,
build: newAppInfo.build,
previous_version: oldAppInfo.version,
previous_build: oldAppInfo.build,
})
}
}
if (store.session.hasSession) {
segmentClient.track('Application Opened', {
from_background: false,
segmentClient.track('Application Installed', {
version: newAppInfo.version,
build: newAppInfo.build,
})
} else if (newAppInfo.version !== oldAppInfo.version) {
segmentClient.track('Application Updated', {
version: newAppInfo.version,
build: newAppInfo.build,
previous_version: oldAppInfo.version,
previous_build: oldAppInfo.build,
})
}
segmentClient.track('Application Opened', {
from_background: false,
version: newAppInfo.version,
build: newAppInfo.build,
})
})
let lastState: AppStateStatus = AppState.currentState
@@ -129,3 +131,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider>
)
}
async function writeAppInfo(value: AppInfo) {
await AsyncStorage.setItem('BSKY_APP_INFO', JSON.stringify(value))
}
async function readAppInfo(): Promise<AppInfo | undefined> {
const rawData = await AsyncStorage.getItem('BSKY_APP_INFO')
const obj = rawData ? JSON.parse(rawData) : undefined
if (!obj || typeof obj !== 'object') {
return undefined
}
return obj
}
+14 -13
View File
@@ -4,10 +4,12 @@ import {
AnalyticsProvider,
useAnalytics as useAnalyticsOrig,
} from '@segment/analytics-react'
import {RootStoreModel} from 'state/models/root-store'
import {useStores} from 'state/models/root-store'
import {sha256} from 'js-sha256'
import {useSession} from '#/state/session'
import {logger} from '#/logger'
import {listenSessionLoaded} from '#/state/events'
const segmentClient = createClient(
{
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
@@ -23,10 +25,10 @@ const segmentClient = createClient(
export const track = segmentClient?.track?.bind?.(segmentClient)
export function useAnalytics() {
const store = useStores()
const {hasSession} = useSession()
const methods = useAnalyticsOrig()
return React.useMemo(() => {
if (store.session.hasSession) {
if (hasSession) {
return methods
}
// dont send analytics pings for anonymous users
@@ -39,19 +41,18 @@ export function useAnalytics() {
alias: () => {},
reset: () => {},
}
}, [store, methods])
}, [hasSession, methods])
}
export function init(store: RootStoreModel) {
store.onSessionLoaded(() => {
const sess = store.session.currentSession
if (sess) {
if (sess.did) {
const did_hashed = sha256(sess.did)
export function init() {
listenSessionLoaded(account => {
if (account.did) {
if (account.did) {
const did_hashed = sha256(account.did)
segmentClient.identify(did_hashed, {did_hashed})
store.log.debug('Ping w/hash')
logger.debug('Ping w/hash')
} else {
store.log.debug('Ping w/o hash')
logger.debug('Ping w/o hash')
segmentClient.identify()
}
}
+7 -4
View File
@@ -97,10 +97,13 @@ interface TrackPropertiesMap {
// LISTS events
'Lists:onRefresh': {}
'Lists:onEndReached': {}
'CreateMuteList:AvatarSelected': {}
'CreateMuteList:Save': {} // CAN BE SERVER
'Lists:Subscribe': {} // CAN BE SERVER
'Lists:Unsubscribe': {} // CAN BE SERVER
'CreateList:AvatarSelected': {}
'CreateList:SaveCurateList': {} // CAN BE SERVER
'CreateList:SaveModList': {} // CAN BE SERVER
'Lists:Mute': {} // CAN BE SERVER
'Lists:Unmute': {} // CAN BE SERVER
'Lists:Block': {} // CAN BE SERVER
'Lists:Unblock': {} // CAN BE SERVER
// CUSTOM FEED events
'CustomFeed:Save': {}
'CustomFeed:Unsave': {}
+33 -18
View File
@@ -4,7 +4,7 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
} from '@atproto/api'
import {FeedSourceInfo} from './feed/types'
import {ReasonFeedSource} from './feed/types'
import {isPostInLanguage} from '../../locale/helpers'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
@@ -16,13 +16,7 @@ export type FeedTunerFn = (
export class FeedViewPostsSlice {
isFlattenedReply = false
constructor(public items: FeedViewPost[] = []) {}
get _reactKey() {
return `slice-${this.items[0].post.uri}-${
this.items[0].reason?.indexedAt || this.items[0].post.indexedAt
}`
}
constructor(public items: FeedViewPost[], public _reactKey: string) {}
get uri() {
if (this.isFlattenedReply) {
@@ -65,9 +59,9 @@ export class FeedViewPostsSlice {
)
}
get source(): FeedSourceInfo | undefined {
get source(): ReasonFeedSource | undefined {
return this.items.find(item => '__source' in item && !!item.__source)
?.__source as FeedSourceInfo
?.__source as ReasonFeedSource
}
containsUri(uri: string) {
@@ -116,18 +110,35 @@ export class FeedViewPostsSlice {
}
}
export class FeedTuner {
seenUris: Set<string> = new Set()
constructor() {}
export class NoopFeedTuner {
private keyCounter = 0
reset() {
this.keyCounter = 0
}
tune(
feed: FeedViewPost[],
_opts?: {dryRun: boolean; maintainOrder: boolean},
): FeedViewPostsSlice[] {
return feed.map(
item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
)
}
}
export class FeedTuner {
private keyCounter = 0
seenUris: Set<string> = new Set()
constructor(public tunerFns: FeedTunerFn[]) {}
reset() {
this.keyCounter = 0
this.seenUris.clear()
}
tune(
feed: FeedViewPost[],
tunerFns: FeedTunerFn[] = [],
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
dryRun: false,
maintainOrder: false,
@@ -136,7 +147,9 @@ export class FeedTuner {
let slices: FeedViewPostsSlice[] = []
if (maintainOrder) {
slices = feed.map(item => new FeedViewPostsSlice([item]))
slices = feed.map(
item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
)
} else {
// arrange the posts into thread slices
for (let i = feed.length - 1; i >= 0; i--) {
@@ -152,12 +165,14 @@ export class FeedTuner {
continue
}
}
slices.unshift(new FeedViewPostsSlice([item]))
slices.unshift(
new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`),
)
}
}
// run the custom tuners
for (const tunerFn of tunerFns) {
for (const tunerFn of this.tunerFns) {
slices = tunerFn(this, slices.slice())
}
+12 -16
View File
@@ -2,37 +2,33 @@ import {
AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class AuthorFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public rootStore: RootStoreModel,
public params: GetAuthorFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
constructor(public params: GetAuthorFeed.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getAuthorFeed({
const res = await getAgent().getAuthorFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getAuthorFeed({
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().getAuthorFeed({
...this.params,
cursor: this.cursor,
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: this._filter(res.data.feed),
+12 -16
View File
@@ -2,37 +2,33 @@ import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class CustomFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public rootStore: RootStoreModel,
public params: GetCustomFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
constructor(public params: GetCustomFeed.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
const res = await getAgent().app.bsky.feed.getFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().app.bsky.feed.getFeed({
...this.params,
cursor: this.cursor,
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
+12 -13
View File
@@ -1,30 +1,29 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class FollowingFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(public rootStore: RootStoreModel) {}
reset() {
this.cursor = undefined
}
constructor() {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getTimeline({
const res = await getAgent().getTimeline({
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getTimeline({
cursor: this.cursor,
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().getTimeline({
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
+12 -16
View File
@@ -2,37 +2,33 @@ import {
AppBskyFeedDefs,
AppBskyFeedGetActorLikes as GetActorLikes,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class LikesFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public rootStore: RootStoreModel,
public params: GetActorLikes.QueryParams,
) {}
reset() {
this.cursor = undefined
}
constructor(public params: GetActorLikes.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getActorLikes({
const res = await getAgent().getActorLikes({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getActorLikes({
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().getActorLikes({
...this.params,
cursor: this.cursor,
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
+41
View File
@@ -0,0 +1,41 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetListFeed as GetListFeed,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class ListFeedAPI implements FeedAPI {
constructor(public params: GetListFeed.QueryParams) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().app.bsky.feed.getListFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().app.bsky.feed.getListFeed({
...this.params,
cursor,
limit,
})
if (res.success) {
return {
cursor: res.data.cursor,
feed: res.data.feed,
}
}
return {
feed: [],
}
}
}
+77 -67
View File
@@ -1,11 +1,13 @@
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
import shuffle from 'lodash.shuffle'
import {RootStoreModel} from 'state/index'
import {timeout} from 'lib/async/timeout'
import {bundleAsync} from 'lib/async/bundle'
import {feedUriToHref} from 'lib/strings/url-helpers'
import {FeedTuner} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip'
import {getAgent} from '#/state/session'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -17,28 +19,44 @@ export class MergeFeedAPI implements FeedAPI {
itemCursor = 0
sampleCursor = 0
constructor(public rootStore: RootStoreModel) {
this.following = new MergeFeedSource_Following(this.rootStore)
constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) {
this.following = new MergeFeedSource_Following(this.feedTuners)
}
reset() {
this.following = new MergeFeedSource_Following(this.rootStore)
this.following = new MergeFeedSource_Following(this.feedTuners)
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
this.feedCursor = 0
this.itemCursor = 0
this.sampleCursor = 0
if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
this.customFeeds = shuffle(
this.params.mergeFeedSources.map(
feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners),
),
)
} else {
this.customFeeds = []
}
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getTimeline({
const res = await getAgent().getTimeline({
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
// we capture here to ensure the data has loaded
this._captureFeedsIfNeeded()
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
if (!cursor) {
this.reset()
}
const promises = []
@@ -76,7 +94,7 @@ export class MergeFeedAPI implements FeedAPI {
}
return {
cursor: posts.length ? 'fake' : undefined,
cursor: posts.length ? String(this.itemCursor) : undefined,
feed: posts,
}
}
@@ -107,33 +125,15 @@ export class MergeFeedAPI implements FeedAPI {
// provide follow
return this.following.take(1)
}
_captureFeedsIfNeeded() {
if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) {
return
}
if (this.customFeeds.length === 0) {
this.customFeeds = shuffle(
this.rootStore.me.savedFeeds.all.map(
feed =>
new MergeFeedSource_Custom(
this.rootStore,
feed.uri,
feed.displayName,
),
),
)
}
}
}
class MergeFeedSource {
sourceInfo: FeedSourceInfo | undefined
sourceInfo: ReasonFeedSource | undefined
cursor: string | undefined = undefined
queue: AppBskyFeedDefs.FeedViewPost[] = []
hasMore = true
constructor(public rootStore: RootStoreModel) {}
constructor(public feedTuners: FeedTunerFn[]) {}
get numReady() {
return this.queue.length
@@ -180,7 +180,7 @@ class MergeFeedSource {
}
class MergeFeedSource_Following extends MergeFeedSource {
tuner = new FeedTuner()
tuner = new FeedTuner(this.feedTuners)
reset() {
super.reset()
@@ -195,16 +195,12 @@ class MergeFeedSource_Following extends MergeFeedSource {
cursor: string | undefined,
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.rootStore.agent.getTimeline({cursor, limit})
const res = await getAgent().getTimeline({cursor, limit})
// run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune(
res.data.feed,
this.rootStore.preferences.getFeedTuners('home'),
{
dryRun: false,
maintainOrder: true,
},
)
const slices = this.tuner.tune(res.data.feed, {
dryRun: false,
maintainOrder: true,
})
res.data.feed = slices.map(slice => slice.rootItem)
return res
}
@@ -213,43 +209,57 @@ class MergeFeedSource_Following extends MergeFeedSource {
class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date
constructor(
public rootStore: RootStoreModel,
public feedUri: string,
public feedDisplayName: string,
) {
super(rootStore)
constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) {
super(feedTuners)
this.sourceInfo = {
displayName: feedDisplayName,
$type: 'reasonFeedSource',
displayName: feedUri.split('/').pop() || '',
uri: feedUriToHref(feedUri),
}
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
getAgent()
.app.bsky.feed.getFeedGenerator({
feed: feedUri,
})
.then(
res => {
if (this.sourceInfo) {
this.sourceInfo.displayName = res.data.view.displayName
}
},
_err => {},
)
}
protected async _getFeed(
cursor: string | undefined,
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
cursor,
limit,
feed: this.feedUri,
})
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
// -prf
if (limit && res.data.feed.length > limit) {
res.data.feed = res.data.feed.slice(0, limit)
try {
const res = await getAgent().app.bsky.feed.getFeed({
cursor,
limit,
feed: this.feedUri,
})
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
// -prf
if (limit && res.data.feed.length > limit) {
res.data.feed = res.data.feed.slice(0, limit)
}
// filter out older posts
res.data.feed = res.data.feed.filter(
post => new Date(post.post.indexedAt) > this.minDate,
)
// attach source info
for (const post of res.data.feed) {
post.__source = this.sourceInfo
}
return res
} catch {
// dont bubble custom-feed errors
return {success: false, headers: {}, data: {feed: []}}
}
// filter out older posts
res.data.feed = res.data.feed.filter(
post => new Date(post.post.indexedAt) > this.minDate,
)
// attach source info
for (const post of res.data.feed) {
post.__source = this.sourceInfo
}
return res
}
}
+18 -3
View File
@@ -6,12 +6,27 @@ export interface FeedAPIResponse {
}
export interface FeedAPI {
reset(): void
peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse>
}
export interface FeedSourceInfo {
export interface ReasonFeedSource {
$type: 'reasonFeedSource'
uri: string
displayName: string
}
export function isReasonFeedSource(v: unknown): v is ReasonFeedSource {
return (
!!v &&
typeof v === 'object' &&
'$type' in v &&
v.$type === 'reasonFeedSource'
)
}
+14 -42
View File
@@ -4,17 +4,18 @@ import {
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyRichtextFacet,
BskyAgent,
ComAtprotoLabelDefs,
ComAtprotoRepoUploadBlob,
RichText,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {RootStoreModel} from 'state/models/root-store'
import {isNetworkError} from 'lib/strings/errors'
import {LinkMeta} from '../link-meta/link-meta'
import {isWeb} from 'platform/detection'
import {ImageModel} from 'state/models/media/image'
import {shortenLinks} from 'lib/strings/rich-text-manip'
import {logger} from '#/logger'
export interface ExternalEmbedDraft {
uri: string
@@ -24,46 +25,19 @@ export interface ExternalEmbedDraft {
localThumb?: ImageModel
}
export async function resolveName(store: RootStoreModel, didOrHandle: string) {
if (!didOrHandle) {
throw new Error('Invalid handle: ""')
}
if (didOrHandle.startsWith('did:')) {
return didOrHandle
}
// we run the resolution always to ensure freshness
const promise = store.agent
.resolveHandle({
handle: didOrHandle,
})
.then(res => {
store.handleResolutions.cache.set(didOrHandle, res.data.did)
return res.data.did
})
// but we can return immediately if it's cached
const cached = store.handleResolutions.cache.get(didOrHandle)
if (cached) {
return cached
}
return promise
}
export async function uploadBlob(
store: RootStoreModel,
agent: BskyAgent,
blob: string,
encoding: string,
): Promise<ComAtprotoRepoUploadBlob.Response> {
if (isWeb) {
// `blob` should be a data uri
return store.agent.uploadBlob(convertDataURIToUint8Array(blob), {
return agent.uploadBlob(convertDataURIToUint8Array(blob), {
encoding,
})
} else {
// `blob` should be a path to a file in the local FS
return store.agent.uploadBlob(
return agent.uploadBlob(
blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding},
)
@@ -80,12 +54,11 @@ interface PostOpts {
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]
knownHandles?: Set<string>
onStateChange?: (state: string) => void
langs?: string[]
}
export async function post(store: RootStoreModel, opts: PostOpts) {
export async function post(agent: BskyAgent, opts: PostOpts) {
let embed:
| AppBskyEmbedImages.Main
| AppBskyEmbedExternal.Main
@@ -101,7 +74,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
)
opts.onStateChange?.('Processing...')
await rt.detectFacets(store.agent)
await rt.detectFacets(agent)
rt = shortenLinks(rt)
// filter out any mention facets that didn't map to a user
@@ -134,7 +107,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
await image.compress()
const path = image.compressed?.path ?? image.path
const {width, height} = image.compressed || image
const res = await uploadBlob(store, path, 'image/jpeg')
const res = await uploadBlob(agent, path, 'image/jpeg')
images.push({
image: res.data.blob,
alt: image.altText ?? '',
@@ -178,14 +151,13 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
) {
encoding = 'image/jpeg'
} else {
store.log.warn(
'Unexpected image format for thumbnail, skipping',
opts.extLink.localThumb.path,
)
logger.warn('Unexpected image format for thumbnail, skipping', {
thumbnail: opts.extLink.localThumb.path,
})
}
if (encoding) {
const thumbUploadRes = await uploadBlob(
store,
agent,
opts.extLink.localThumb.path,
encoding,
)
@@ -224,7 +196,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
// add replyTo if post is a reply to another post
if (opts.replyTo) {
const replyToUrip = new AtUri(opts.replyTo)
const parentPost = await store.agent.getPost({
const parentPost = await agent.getPost({
repo: replyToUrip.host,
rkey: replyToUrip.rkey,
})
@@ -257,7 +229,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
try {
opts.onStateChange?.('Posting...')
return await store.agent.post({
return await agent.post({
text: rt.text,
facets: rt.facets,
reply,
+25
View File
@@ -0,0 +1,25 @@
export interface AccumulateResponse<T> {
cursor?: string
items: T[]
}
export type AccumulateFetchFn<T> = (
cursor: string | undefined,
) => Promise<AccumulateResponse<T>>
export async function accumulate<T>(
fn: AccumulateFetchFn<T>,
pageLimit = 100,
): Promise<T[]> {
let cursor: string | undefined
let acc: T[] = []
for (let i = 0; i < pageLimit; i++) {
const res = await fn(cursor)
cursor = res.cursor
acc = acc.concat(res.items)
if (!cursor) {
break
}
}
return acc
}
-68
View File
@@ -1,68 +0,0 @@
import {runInAction} from 'mobx'
import {deepObserve} from 'mobx-utils'
import set from 'lodash.set'
const ongoingActions = new Set<any>()
/**
* This is a TypeScript function that optimistically updates data on the client-side before sending a
* request to the server and rolling back changes if the request fails.
* @param {T} model - The object or record that needs to be updated optimistically.
* @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It
* can be used to perform any necessary actions or updates on the model or UI before the server update
* is initiated.
* @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server
* update operation. This function is called after the previous state of the model has been recorded
* and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate`
* function is called with the result
* @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the
* server update is successful. It takes in the response from the server update as its parameter. If
* this parameter is not provided, nothing will happen after the server update.
* @returns A Promise that resolves to `void`.
*/
export const updateDataOptimistically = async <
T extends Record<string, any>,
U,
>(
model: T,
preUpdate: () => void,
serverUpdate: () => Promise<U>,
postUpdate?: (res: U) => void,
): Promise<void> => {
if (ongoingActions.has(model)) {
return
}
ongoingActions.add(model)
const prevState: Map<string, any> = new Map<string, any>()
const dispose = deepObserve(model, (change, path) => {
if (change.observableKind === 'object') {
if (change.type === 'update') {
prevState.set(
[path, change.name].filter(Boolean).join('.'),
change.oldValue,
)
} else if (change.type === 'add') {
prevState.set([path, change.name].filter(Boolean).join('.'), undefined)
}
}
})
preUpdate()
dispose()
try {
const res = await serverUpdate()
runInAction(() => {
postUpdate?.(res)
})
} catch (error) {
runInAction(() => {
prevState.forEach((value, path) => {
set(model, path, value)
})
})
throw error
} finally {
ongoingActions.delete(model)
}
}
+24
View File
@@ -0,0 +1,24 @@
import {timeout} from './timeout'
export async function until(
retries: number,
delay: number,
cond: (v: any, err: any) => boolean,
fn: () => Promise<any>,
): Promise<boolean> {
while (retries > 0) {
try {
const v = await fn()
if (cond(v, undefined)) {
return true
}
} catch (e: any) {
if (cond(undefined, e)) {
return true
}
}
await timeout(delay)
retries--
}
return false
}
+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'
+11
View File
@@ -0,0 +1,11 @@
export default class BroadcastChannel {
constructor(public name: string) {}
postMessage(_data: any) {}
close() {}
onmessage: (event: MessageEvent) => void = () => {}
addEventListener(_type: string, _listener: (event: MessageEvent) => void) {}
removeEventListener(
_type: string,
_listener: (event: MessageEvent) => void,
) {}
}
+1
View File
@@ -0,0 +1 @@
export default BroadcastChannel
+11 -2
View File
@@ -1,4 +1,10 @@
import {Insets} from 'react-native'
import {Insets, Platform} from 'react-native'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
export const STAGING_SERVICE = 'https://staging.bsky.dev'
export const PROD_SERVICE = 'https://bsky.social'
export const DEFAULT_SERVICE = PROD_SERVICE
const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
@@ -43,7 +49,10 @@ export function IS_PROD(url: string) {
// until open federation, "production" is defined as the main server
// this definition will not work once federation is enabled!
// -prf
return url.startsWith('https://bsky.social')
return (
url.startsWith('https://bsky.social') ||
url.startsWith('https://api.bsky.app')
)
}
export const PROD_TEAM_HANDLES = [
+42 -29
View File
@@ -1,41 +1,54 @@
import {useCallback, useState} from 'react'
import {useStores} from 'state/index'
import {useAnalytics} from 'lib/analytics/analytics'
import {StackActions, useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {AccountData} from 'state/models/session'
import {reset as resetNavigation} from '../../Navigation'
import * as Toast from 'view/com/util/Toast'
import {useCallback} from 'react'
import {useNavigation} from '@react-navigation/native'
export function useAccountSwitcher(): [
boolean,
(v: boolean) => void,
(acct: AccountData) => Promise<void>,
] {
import {isWeb} from '#/platform/detection'
import {NavigationProp} from '#/lib/routes/types'
import {useAnalytics} from '#/lib/analytics/analytics'
import {useSessionApi, SessionAccount} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {useCloseAllActiveElements} from '#/state/util'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
export function useAccountSwitcher() {
const {track} = useAnalytics()
const store = useStores()
const [isSwitching, setIsSwitching] = useState(false)
const {selectAccount, clearCurrentAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const navigation = useNavigation<NavigationProp>()
const {setShowLoggedOut} = useLoggedOutViewControls()
const onPressSwitchAccount = useCallback(
async (acct: AccountData) => {
async (account: SessionAccount) => {
track('Settings:SwitchAccountButtonClicked')
setIsSwitching(true)
const success = await store.session.resumeSession(acct)
store.shell.closeAllActiveElements()
if (success) {
resetNavigation()
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
} else {
try {
if (account.accessJwt) {
closeAllActiveElements()
navigation.navigate(isWeb ? 'Home' : 'HomeTab')
await selectAccount(account)
setTimeout(() => {
Toast.show(`Signed in as @${account.handle}`)
}, 100)
} else {
setShowLoggedOut(true)
Toast.show(
`Please sign in as @${account.handle}`,
'circle-exclamation',
)
}
} catch (e) {
Toast.show('Sorry! We need you to enter your password.')
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
clearCurrentAccount() // back user out to login
}
},
[track, setIsSwitching, navigation, store],
[
track,
clearCurrentAccount,
selectAccount,
closeAllActiveElements,
navigation,
setShowLoggedOut,
],
)
return [isSwitching, setIsSwitching, onPressSwitchAccount]
return {onPressSwitchAccount}
}
@@ -0,0 +1,15 @@
// Be warned. This Hook is very buggy unless used in a very constrained way.
// To use it safely:
//
// - DO NOT pass its return value as a prop to any user-defined component.
// - DO NOT pass its return value to more than a single component.
//
// In other words, the only safe way to use it is next to the leaf Reanimated View.
//
// Relevant bug reports:
// - https://github.com/software-mansion/react-native-reanimated/issues/5345
// - https://github.com/software-mansion/react-native-reanimated/issues/5360
// - https://github.com/software-mansion/react-native-reanimated/issues/5364
//
// It's great when it works though.
export {useAnimatedScrollHandler} from 'react-native-reanimated'
@@ -0,0 +1,44 @@
import {useRef, useEffect} from 'react'
import {useAnimatedScrollHandler as useAnimatedScrollHandler_BUGGY} from 'react-native-reanimated'
export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = (
config,
deps,
) => {
const ref = useRef(config)
useEffect(() => {
ref.current = config
})
return useAnimatedScrollHandler_BUGGY(
{
onBeginDrag(e) {
if (typeof ref.current !== 'function' && ref.current.onBeginDrag) {
ref.current.onBeginDrag(e)
}
},
onEndDrag(e) {
if (typeof ref.current !== 'function' && ref.current.onEndDrag) {
ref.current.onEndDrag(e)
}
},
onMomentumBegin(e) {
if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) {
ref.current.onMomentumBegin(e)
}
},
onMomentumEnd(e) {
if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) {
ref.current.onMomentumEnd(e)
}
},
onScroll(e) {
if (typeof ref.current === 'function') {
ref.current(e)
} else if (ref.current.onScroll) {
ref.current.onScroll(e)
}
},
},
deps,
)
}
-27
View File
@@ -1,27 +0,0 @@
import {useEffect, useState} from 'react'
import {useStores} from 'state/index'
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
export function useCustomFeed(uri: string): CustomFeedModel | undefined {
const store = useStores()
const [item, setItem] = useState<CustomFeedModel | undefined>()
useEffect(() => {
async function fetchView() {
const res = await store.agent.app.bsky.feed.getFeedGenerator({
feed: uri,
})
const view = res.data.view
return view
}
async function buildFeedItem() {
const view = await fetchView()
if (view) {
const temp = new CustomFeedModel(store, view)
setItem(temp)
}
}
buildFeedItem()
}, [store, uri])
return item
}
-54
View File
@@ -1,54 +0,0 @@
import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {useStores} from 'state/index'
import {FollowState} from 'state/models/cache/my-follows'
export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
const store = useStores()
const state = store.me.follows.getFollowState(profile.did)
return {
state,
following: state === FollowState.Following,
toggle: React.useCallback(async () => {
if (state === FollowState.Following) {
try {
await store.agent.deleteFollow(
store.me.follows.getFollowUri(profile.did),
)
store.me.follows.removeFollow(profile.did)
return {
state: FollowState.NotFollowing,
following: false,
}
} catch (e: any) {
store.log.error('Failed to delete follow', e)
throw e
}
} else if (state === FollowState.NotFollowing) {
try {
const res = await store.agent.follow(profile.did)
store.me.follows.addFollow(profile.did, {
followRecordUri: res.uri,
did: profile.did,
handle: profile.handle,
displayName: profile.displayName,
avatar: profile.avatar,
})
return {
state: FollowState.Following,
following: true,
}
} catch (e: any) {
store.log.error('Failed to create follow', e)
throw e
}
}
return {
state: FollowState.Unknown,
following: false,
}
}, [store, profile, state]),
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ export function useIsKeyboardVisible({
const [isKeyboardVisible, setKeyboardVisible] = useState(false)
// NOTE
// only iOS suppose the "will" events
// only iOS supports the "will" events
// -prf
const showEvent =
isIOS && iosUseWillEvents ? 'keyboardWillShow' : 'keyboardDidShow'
+19 -34
View File
@@ -1,57 +1,42 @@
import React from 'react'
import {autorun} from 'mobx'
import {useStores} from 'state/index'
import {
Easing,
interpolate,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout'
export function useMinimalShellMode() {
const store = useStores()
const minimalShellInterp = useSharedValue(0)
const mode = useMinimalShellModeState()
const {footerHeight, headerHeight} = useShellLayout()
const footerMinimalShellTransform = useAnimatedStyle(() => {
return {
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2),
transform: [
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])},
{
translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
},
],
}
})
const headerMinimalShellTransform = useAnimatedStyle(() => {
return {
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2),
transform: [
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])},
{
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
},
],
}
})
const fabMinimalShellTransform = useAnimatedStyle(() => {
return {
transform: [
{translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])},
{
translateY: interpolate(mode.value, [0, 1], [-44, 0]),
},
],
}
})
React.useEffect(() => {
return autorun(() => {
if (store.shell.minimalShellMode) {
minimalShellInterp.value = withTiming(1, {
duration: 125,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
})
} else {
minimalShellInterp.value = withTiming(0, {
duration: 125,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
})
}
})
}, [minimalShellInterp, store.shell.minimalShellMode])
return {
footerMinimalShellTransform,
headerMinimalShellTransform,
+23
View File
@@ -0,0 +1,23 @@
import {useCallback, useInsertionEffect, useRef} from 'react'
// This should be used sparingly. It erases reactivity, i.e. when the inputs
// change, the function itself will remain the same. This means that if you
// use this at a higher level of your tree, and then some state you read in it
// changes, there is no mechanism for anything below in the tree to "react"
// to this change (e.g. by knowing to call your function again).
//
// Also, you should avoid calling the returned function during rendering
// since the values captured by it are going to lag behind.
export function useNonReactiveCallback<T extends Function>(fn: T): T {
const ref = useRef(fn)
useInsertionEffect(() => {
ref.current = fn
}, [fn])
return useCallback(
(...args: any) => {
const latestFn = ref.current
return latestFn(...args)
},
[ref],
) as unknown as T
}
+18 -17
View File
@@ -1,27 +1,28 @@
import * as Updates from 'expo-updates'
import {useCallback, useEffect} from 'react'
import {AppState} from 'react-native'
import {useStores} from 'state/index'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {t} from '@lingui/macro'
export function useOTAUpdate() {
const store = useStores()
const {openModal} = useModalControls()
// HELPER FUNCTIONS
const showUpdatePopup = useCallback(() => {
store.shell.openModal({
openModal({
name: 'confirm',
title: 'Update Available',
message:
'A new version of the app is available. Please update to continue using the app.',
title: t`Update Available`,
message: t`A new version of the app is available. Please update to continue using the app.`,
onPressConfirm: async () => {
Updates.reloadAsync().catch(err => {
throw err
})
},
})
}, [store.shell])
}, [openModal])
const checkForUpdate = useCallback(async () => {
store.log.debug('useOTAUpdate: Checking for update...')
logger.debug('useOTAUpdate: Checking for update...')
try {
// Check if new OTA update is available
const update = await Updates.checkForUpdateAsync()
@@ -34,18 +35,18 @@ export function useOTAUpdate() {
// show a popup modal
showUpdatePopup()
} catch (e) {
console.error('useOTAUpdate: Error while checking for update', e)
store.log.error('useOTAUpdate: Error while checking for update', e)
logger.error('useOTAUpdate: Error while checking for update', {
error: e,
})
}
}, [showUpdatePopup, store.log])
}, [showUpdatePopup])
const updateEventListener = useCallback(
(event: Updates.UpdateEvent) => {
store.log.debug('useOTAUpdate: Listening for update...')
logger.debug('useOTAUpdate: Listening for update...')
if (event.type === Updates.UpdateEventType.ERROR) {
store.log.error(
'useOTAUpdate: Error while listening for update',
event.message,
)
logger.error('useOTAUpdate: Error while listening for update', {
message: event.message,
})
} else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
// Handle no update available
// do nothing
@@ -55,7 +56,7 @@ export function useOTAUpdate() {
showUpdatePopup()
}
},
[showUpdatePopup, store.log],
[showUpdatePopup],
)
useEffect(() => {
+108 -49
View File
@@ -1,66 +1,125 @@
import {useState, useCallback, useRef} from 'react'
import {useState, useCallback, useMemo} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {RootStoreModel} from 'state/index'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {s} from 'lib/styles'
import {useWebMediaQueries} from './useWebMediaQueries'
import {isWeb} from 'platform/detection'
import {
useSharedValue,
interpolate,
runOnJS,
ScrollHandlers,
} from 'react-native-reanimated'
const Y_LIMIT = 10
const useDeviceLimits = () => {
const {isDesktop} = useWebMediaQueries()
return {
dyLimitUp: isDesktop ? 30 : 10,
dyLimitDown: isDesktop ? 150 : 10,
}
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
}
export type OnScrollCb = (
event: NativeSyntheticEvent<NativeScrollEvent>,
) => void
export type OnScrollHandler = ScrollHandlers<any>
export type ResetCb = () => void
export function useOnMainScroll(
store: RootStoreModel,
): [OnScrollCb, boolean, ResetCb] {
let lastY = useRef(0)
let [isScrolledDown, setIsScrolledDown] = useState(false)
const {dyLimitUp, dyLimitDown} = useDeviceLimits()
export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] {
const {headerHeight} = useShellLayout()
const [isScrolledDown, setIsScrolledDown] = useState(false)
const mode = useMinimalShellMode()
const setMode = useSetMinimalShellMode()
const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null)
const onBeginDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
},
[mode, startDragOffset, startMode],
)
const onEndDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
startDragOffset.value = null
startMode.value = null
if (e.contentOffset.y < headerHeight.value / 2) {
// If we're close to the top, show the shell.
setMode(false)
} else {
// Snap to whichever state is the closest.
setMode(Math.round(mode.value) === 1)
}
},
[startDragOffset, startMode, setMode, mode, headerHeight],
)
const onScroll = useCallback(
(e: NativeScrollEvent) => {
'worklet'
// Keep track of whether we want to show "scroll to top".
if (!isScrolledDown && e.contentOffset.y > s.window.height) {
runOnJS(setIsScrolledDown)(true)
} else if (isScrolledDown && e.contentOffset.y < s.window.height) {
runOnJS(setIsScrolledDown)(false)
}
if (startDragOffset.value === null || startMode.value === null) {
if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
// If we're close enough to the top, always show the shell.
// Even if we're not dragging.
setMode(false)
return
}
if (isWeb) {
// On the web, there is no concept of "starting" the drag.
// When we get the first scroll event, we consider that the start.
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
}
return
}
// The "mode" value is always between 0 and 1.
// Figure out how much to move it based on the current dragged distance.
const dy = e.contentOffset.y - startDragOffset.value
const dProgress = interpolate(
dy,
[-headerHeight.value, headerHeight.value],
[-1, 1],
)
const newValue = clamp(startMode.value + dProgress, 0, 1)
if (newValue !== mode.value) {
// Manually adjust the value. This won't be (and shouldn't be) animated.
mode.value = newValue
}
if (isWeb) {
// On the web, there is no concept of "starting" the drag,
// so we don't have any specific anchor point to calculate the distance.
// Instead, update it continuosly along the way and diff with the last event.
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
}
},
[headerHeight, mode, setMode, isScrolledDown, startDragOffset, startMode],
)
const scrollHandler: ScrollHandlers<any> = useMemo(
() => ({
onBeginDrag,
onEndDrag,
onScroll,
}),
[onBeginDrag, onEndDrag, onScroll],
)
return [
useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y
const dy = y - (lastY.current || 0)
lastY.current = y
if (!store.shell.minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
store.shell.setMinimalShellMode(true)
} else if (
store.shell.minimalShellMode &&
(dy < dyLimitUp * -1 || y <= Y_LIMIT)
) {
store.shell.setMinimalShellMode(false)
}
if (
!isScrolledDown &&
event.nativeEvent.contentOffset.y > s.window.height
) {
setIsScrolledDown(true)
} else if (
isScrolledDown &&
event.nativeEvent.contentOffset.y < s.window.height
) {
setIsScrolledDown(false)
}
},
[store.shell, dyLimitDown, dyLimitUp, isScrolledDown],
),
scrollHandler,
isScrolledDown,
useCallback(() => {
setIsScrolledDown(false)
store.shell.setMinimalShellMode(false)
lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
}, [store, setIsScrolledDown]),
setMode(false)
}, [setMode]),
]
}
+4 -8
View File
@@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings'
import {useStores} from 'state/index'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
/**
* Requires consuming component to be wrapped in `observer`:
* https://stackoverflow.com/a/71488009
*/
export function useSetTitle(title?: string) {
const navigation = useNavigation<NavigationProp>()
const {unreadCountLabel} = useStores().me.notifications
const numUnread = useUnreadNotifications()
useEffect(() => {
if (title) {
navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
navigation.setOptions({title: bskyTitle(title, numUnread)})
}
}, [title, navigation, unreadCountLabel])
}, [title, navigation, numUnread])
}
+98
View File
@@ -0,0 +1,98 @@
import {useState, useRef, useEffect, useCallback} from 'react'
type Task<TServerState> = {
isOn: boolean
resolve: (serverState: TServerState) => void
reject: (e: unknown) => void
}
type TaskQueue<TServerState> = {
activeTask: Task<TServerState> | null
queuedTask: Task<TServerState> | null
}
function AbortError() {
const e = new Error()
e.name = 'AbortError'
return e
}
export function useToggleMutationQueue<TServerState>({
initialState,
runMutation,
onSuccess,
}: {
initialState: TServerState
runMutation: (
prevState: TServerState,
nextIsOn: boolean,
) => Promise<TServerState>
onSuccess: (finalState: TServerState) => void
}) {
// We use the queue as a mutable object.
// This is safe becuase it is not used for rendering.
const [queue] = useState<TaskQueue<TServerState>>({
activeTask: null,
queuedTask: null,
})
async function processQueue() {
if (queue.activeTask) {
// There is another active processQueue call iterating over tasks.
// It will handle any newly added tasks, so we should exit early.
return
}
// To avoid relying on the rendered state, capture it once at the start.
// From that point on, and until the queue is drained, we'll use the real server state.
let confirmedState: TServerState = initialState
try {
while (queue.queuedTask) {
const prevTask = queue.activeTask
const nextTask = queue.queuedTask
queue.activeTask = nextTask
queue.queuedTask = null
if (prevTask?.isOn === nextTask.isOn) {
// Skip multiple requests to update to the same value in a row.
prevTask.reject(new (AbortError as any)())
continue
}
try {
// The state received from the server feeds into the next task.
// This lets us queue deletions of not-yet-created resources.
confirmedState = await runMutation(confirmedState, nextTask.isOn)
nextTask.resolve(confirmedState)
} catch (e) {
nextTask.reject(e)
}
}
} finally {
onSuccess(confirmedState)
queue.activeTask = null
queue.queuedTask = null
}
}
function queueToggle(isOn: boolean): Promise<TServerState> {
return new Promise((resolve, reject) => {
// This is a toggle, so the next queued value can safely replace the queued one.
if (queue.queuedTask) {
queue.queuedTask.reject(new (AbortError as any)())
}
queue.queuedTask = {isOn, resolve, reject}
processQueue()
})
}
const queueToggleRef = useRef(queueToggle)
useEffect(() => {
queueToggleRef.current = queueToggle
})
const queueToggleStable = useCallback(
(isOn: boolean): Promise<TServerState> => {
const queueToggleLatest = queueToggleRef.current
return queueToggleLatest(isOn)
},
[],
)
return queueToggleStable
}
+27
View File
@@ -947,3 +947,30 @@ export function ShieldExclamation({
</Svg>
)
}
export function ListIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1.5}
stroke="currentColor"
width={size}
height={size}
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</Svg>
)
}
-89
View File
@@ -1,89 +0,0 @@
import {LabelPreferencesModel} from 'state/models/ui/preferences'
import {LabelValGroup} from './types'
export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
id: 'illegal',
title: 'Illegal Content',
warning: 'Illegal Content',
values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
}
export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = {
id: 'always-filter',
title: 'Content Warning',
warning: 'Content Warning',
values: ['!filter'],
}
export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = {
id: 'always-warn',
title: 'Content Warning',
warning: 'Content Warning',
values: ['!warn', 'account-security'],
}
export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
id: 'unknown',
title: 'Unknown Label',
warning: 'Content Warning',
values: [],
}
export const CONFIGURABLE_LABEL_GROUPS: Record<
keyof LabelPreferencesModel,
LabelValGroup
> = {
nsfw: {
id: 'nsfw',
title: 'Explicit Sexual Images',
subtitle: 'i.e. pornography',
warning: 'Sexually Explicit',
values: ['porn', 'nsfl'],
isAdultImagery: true,
},
nudity: {
id: 'nudity',
title: 'Other Nudity',
subtitle: 'Including non-sexual and artistic',
warning: 'Nudity',
values: ['nudity'],
isAdultImagery: true,
},
suggestive: {
id: 'suggestive',
title: 'Sexually Suggestive',
subtitle: 'Does not include nudity',
warning: 'Sexually Suggestive',
values: ['sexual'],
isAdultImagery: true,
},
gore: {
id: 'gore',
title: 'Violent / Bloody',
subtitle: 'Gore, self-harm, torture',
warning: 'Violence',
values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
isAdultImagery: true,
},
hate: {
id: 'hate',
title: 'Hate Group Iconography',
subtitle: 'Images of terror groups, articles covering events, etc.',
warning: 'Hate Groups',
values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
},
spam: {
id: 'spam',
title: 'Spam',
subtitle: 'Excessive unwanted interactions',
warning: 'Spam',
values: ['spam'],
},
impersonation: {
id: 'impersonation',
title: 'Impersonation',
subtitle: 'Accounts falsely claiming to be people or orgs',
warning: 'Impersonation',
values: ['impersonation'],
},
}
-18
View File
@@ -1,18 +0,0 @@
import {ComAtprotoLabelDefs} from '@atproto/api'
import {LabelPreferencesModel} from 'state/models/ui/preferences'
export type Label = ComAtprotoLabelDefs.Label
export interface LabelValGroup {
id:
| keyof LabelPreferencesModel
| 'illegal'
| 'always-filter'
| 'always-warn'
| 'unknown'
title: string
isAdultImagery?: boolean
subtitle?: string
warning: string
values: string[]
}
+16 -24
View File
@@ -1,10 +1,10 @@
import {AppBskyFeedPost, BskyAgent} from '@atproto/api'
import * as apilib from 'lib/api/index'
import {LikelyType, LinkMeta} from './link-meta'
// import {match as matchRoute} from 'view/routes'
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
import {RootStoreModel} from 'state/index'
import {PostThreadModel} from 'state/models/content/post-thread'
import {ComposerOptsQuote} from 'state/models/ui/shell'
import {ComposerOptsQuote} from 'state/shell/composer'
import {useGetPost} from '#/state/queries/post'
// TODO
// import {Home} from 'view/screens/Home'
@@ -22,7 +22,7 @@ import {ComposerOptsQuote} from 'state/models/ui/shell'
// remove once that's implemented
// -prf
export async function extractBskyMeta(
store: RootStoreModel,
agent: BskyAgent,
url: string,
): Promise<LinkMeta> {
url = convertBskyAppUrlIfNeeded(url)
@@ -102,38 +102,30 @@ export async function extractBskyMeta(
}
export async function getPostAsQuote(
store: RootStoreModel,
getPost: ReturnType<typeof useGetPost>,
url: string,
): Promise<ComposerOptsQuote> {
url = convertBskyAppUrlIfNeeded(url)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const threadUri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
const threadView = new PostThreadModel(store, {
uri: threadUri,
depth: 0,
})
await threadView.setup()
if (!threadView.thread || threadView.notFound) {
throw new Error('Not found')
}
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
const post = await getPost({uri: uri})
return {
uri: threadView.thread.post.uri,
cid: threadView.thread.post.cid,
text: threadView.thread.postRecord?.text || '',
indexedAt: threadView.thread.post.indexedAt,
author: threadView.thread.post.author,
uri: post.uri,
cid: post.cid,
text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '',
indexedAt: post.indexedAt,
author: post.author,
}
}
export async function getFeedAsEmbed(
store: RootStoreModel,
agent: BskyAgent,
url: string,
): Promise<apilib.ExternalEmbedDraft> {
url = convertBskyAppUrlIfNeeded(url)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey)
const res = await store.agent.app.bsky.feed.getFeedGenerator({feed})
const res = await agent.app.bsky.feed.getFeedGenerator({feed})
return {
isLoading: false,
uri: feed,
@@ -153,13 +145,13 @@ export async function getFeedAsEmbed(
}
export async function getListAsEmbed(
store: RootStoreModel,
agent: BskyAgent,
url: string,
): Promise<apilib.ExternalEmbedDraft> {
url = convertBskyAppUrlIfNeeded(url)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const list = makeRecordUri(user, 'app.bsky.graph.list', rkey)
const res = await store.agent.app.bsky.graph.getList({list})
const res = await agent.app.bsky.graph.getList({list})
return {
isLoading: false,
uri: list,
+7 -7
View File
@@ -1,5 +1,5 @@
import {BskyAgent} from '@atproto/api'
import {isBskyAppUrl} from '../strings/url-helpers'
import {RootStoreModel} from 'state/index'
import {extractBskyMeta} from './bsky'
import {LINK_META_PROXY} from 'lib/constants'
@@ -23,12 +23,12 @@ export interface LinkMeta {
}
export async function getLinkMeta(
store: RootStoreModel,
agent: BskyAgent,
url: string,
timeout = 5e3,
): Promise<LinkMeta> {
if (isBskyAppUrl(url)) {
return extractBskyMeta(store, url)
return extractBskyMeta(agent, url)
}
let urlp
@@ -55,9 +55,9 @@ export async function getLinkMeta(
const to = setTimeout(() => controller.abort(), timeout || 5e3)
const response = await fetch(
`${LINK_META_PROXY(
store.session.currentSession?.service || '',
)}${encodeURIComponent(url)}`,
`${LINK_META_PROXY(agent.service.toString() || '')}${encodeURIComponent(
url,
)}`,
{signal: controller.signal},
)
@@ -92,7 +92,7 @@ export function getLikelyType(url: URL | string): LikelyType {
}
const ext = url.pathname.split('.').pop() || ''
if (ext === 'html' || ext === 'htm') {
if (ext === 'html' || ext === 'htm' || ext === 'php') {
return LikelyType.HTML
}
const mimeType = EXT_MIME_TYPES[ext]
-12
View File
@@ -1,12 +0,0 @@
import {RootStoreModel} from 'state/index'
import {ImageModel} from 'state/models/media/image'
export async function openAltTextModal(
store: RootStoreModel,
image: ImageModel,
) {
store.shell.openModal({
name: 'alt-text-image',
image,
})
}
+34
View File
@@ -0,0 +1,34 @@
import {Image} from 'react-native'
import type {Dimensions} from 'lib/media/types'
const sizes: Map<string, Dimensions> = new Map()
const activeRequests: Map<string, Promise<Dimensions>> = new Map()
export function get(uri: string): Dimensions | undefined {
return sizes.get(uri)
}
export async function fetch(uri: string): Promise<Dimensions> {
const Dimensions = sizes.get(uri)
if (Dimensions) {
return Dimensions
}
const prom =
activeRequests.get(uri) ||
new Promise<Dimensions>(resolve => {
Image.getSize(
uri,
(width: number, height: number) => resolve({width, height}),
(err: any) => {
console.error('Failed to fetch image dimensions for', uri, err)
resolve({width: 0, height: 0})
},
)
})
activeRequests.set(uri, prom)
const res = await prom
activeRequests.delete(uri)
sizes.set(uri, res)
return res
}
+3 -7
View File
@@ -1,4 +1,3 @@
import {RootStoreModel} from 'state/index'
import {Image as RNImage} from 'react-native-image-crop-picker'
import RNFS from 'react-native-fs'
import {CropperOptions} from './types'
@@ -22,18 +21,15 @@ async function getFile() {
})
}
export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> {
export async function openPicker(): Promise<RNImage[]> {
return [await getFile()]
}
export async function openCamera(_store: RootStoreModel): Promise<RNImage> {
export async function openCamera(): Promise<RNImage> {
return await getFile()
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOptions,
): Promise<RNImage> {
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
return {
path: opts.path,
mime: 'image/jpeg',
+2 -18
View File
@@ -3,23 +3,10 @@ import {
openCropper as openCropperFn,
Image as RNImage,
} from 'react-native-image-crop-picker'
import {RootStoreModel} from 'state/index'
import {CameraOpts, CropperOptions} from './types'
export {openPicker} from './picker.shared'
/**
* NOTE
* These methods all include the RootStoreModel as the first param
* because the web versions require it. The signatures have to remain
* equivalent between the different forms, but the store param is not
* used here.
* -prf
*/
export async function openCamera(
_store: RootStoreModel,
opts: CameraOpts,
): Promise<RNImage> {
export async function openCamera(opts: CameraOpts): Promise<RNImage> {
const item = await openCameraFn({
width: opts.width,
height: opts.height,
@@ -39,10 +26,7 @@ export async function openCamera(
}
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOptions,
) {
export async function openCropper(opts: CropperOptions) {
const item = await openCropperFn({
...opts,
forceJpg: true, // ios only
+4 -10
View File
@@ -1,25 +1,19 @@
/// <reference lib="dom" />
import {CameraOpts, CropperOptions} from './types'
import {RootStoreModel} from 'state/index'
import {Image as RNImage} from 'react-native-image-crop-picker'
export {openPicker} from './picker.shared'
import {unstable__openModal} from '#/state/modals'
export async function openCamera(
_store: RootStoreModel,
_opts: CameraOpts,
): Promise<RNImage> {
export async function openCamera(_opts: CameraOpts): Promise<RNImage> {
// const mediaType = opts.mediaType || 'photo' TODO
throw new Error('TODO')
}
export async function openCropper(
store: RootStoreModel,
opts: CropperOptions,
): Promise<RNImage> {
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
// TODO handle more opts
return new Promise((resolve, reject) => {
store.shell.openModal({
unstable__openModal({
name: 'crop-image',
uri: opts.path,
onSelect: (img?: RNImage) => {
+12 -3
View File
@@ -17,9 +17,18 @@ export function describeModerationCause(
}
}
if (cause.type === 'blocking') {
return {
name: 'User Blocked',
description: 'You have blocked this user. You cannot view their content.',
if (cause.source.type === 'list') {
return {
name: `User Blocked by "${cause.source.list.name}"`,
description:
'You have blocked this user. You cannot view their content.',
}
} else {
return {
name: 'User Blocked',
description:
'You have blocked this user. You cannot view their content.',
}
}
}
if (cause.type === 'blocked-by') {
+53 -27
View File
@@ -1,18 +1,19 @@
import * as Notifications from 'expo-notifications'
import {RootStoreModel} from '../../state'
import {QueryClient} from '@tanstack/react-query'
import {resetToTab} from '../../Navigation'
import {devicePlatform, isIOS} from 'platform/detection'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
import {listenSessionLoaded} from '#/state/events'
const SERVICE_DID = (serviceUrl?: string) =>
serviceUrl?.includes('staging')
? 'did:web:api.staging.bsky.dev'
: 'did:web:api.bsky.app'
export function init(store: RootStoreModel) {
store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
store.onSessionLoaded(async () => {
export function init(queryClient: QueryClient) {
listenSessionLoaded(async (account, agent) => {
// request notifications permission once the user has logged in
const perms = await Notifications.getPermissionsAsync()
if (!perms.granted) {
@@ -23,39 +24,51 @@ export function init(store: RootStoreModel) {
const token = await getPushToken()
if (token) {
try {
await store.agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(store.session.data?.service),
await agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(account.service),
platform: devicePlatform,
token: token.data,
appId: 'xyz.blueskyweb.app',
})
store.log.debug('Notifications: Sent push token (init)', {
type: token.type,
token: token.data,
})
logger.debug(
'Notifications: Sent push token (init)',
{
tokenType: token.type,
token: token.data,
},
logger.DebugContext.notifications,
)
} catch (error) {
store.log.error('Notifications: Failed to set push token', error)
logger.error('Notifications: Failed to set push token', {error})
}
}
// listens for new changes to the push token
// In rare situations, a push token may be changed by the push notification service while the app is running. When a token is rolled, the old one becomes invalid and sending notifications to it will fail. A push token listener will let you handle this situation gracefully by registering the new token with your backend right away.
Notifications.addPushTokenListener(async ({data: t, type}) => {
store.log.debug('Notifications: Push token changed', {t, type})
logger.debug(
'Notifications: Push token changed',
{t, tokenType: type},
logger.DebugContext.notifications,
)
if (t) {
try {
await store.agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(store.session.data?.service),
await agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(account.service),
platform: devicePlatform,
token: t,
appId: 'xyz.blueskyweb.app',
})
store.log.debug('Notifications: Sent push token (event)', {
type,
token: t,
})
logger.debug(
'Notifications: Sent push token (event)',
{
tokenType: type,
token: t,
},
logger.DebugContext.notifications,
)
} catch (error) {
store.log.error('Notifications: Failed to set push token', error)
logger.error('Notifications: Failed to set push token', {error})
}
}
})
@@ -63,10 +76,14 @@ export function init(store: RootStoreModel) {
// handle notifications that are received, both in the foreground or background
Notifications.addNotificationReceivedListener(event => {
store.log.debug('Notifications: received', event)
logger.debug(
'Notifications: received',
{event},
logger.DebugContext.notifications,
)
if (event.request.trigger.type === 'push') {
// refresh notifications in the background
store.me.notifications.syncQueue()
queryClient.resetQueries({queryKey: RQKEY_NOTIFS()})
// handle payload-based deeplinks
let payload
if (isIOS) {
@@ -75,7 +92,11 @@ export function init(store: RootStoreModel) {
// TODO: handle android payload deeplink
}
if (payload) {
store.log.debug('Notifications: received payload', payload)
logger.debug(
'Notifications: received payload',
payload,
logger.DebugContext.notifications,
)
// TODO: deeplink notif here
}
}
@@ -84,18 +105,23 @@ export function init(store: RootStoreModel) {
// handle notifications that are tapped on
const sub = Notifications.addNotificationResponseReceivedListener(
response => {
store.log.debug(
logger.debug(
'Notifications: response received',
response.actionIdentifier,
{
actionIdentifier: response.actionIdentifier,
},
logger.DebugContext.notifications,
)
if (
response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER
) {
store.log.debug(
logger.debug(
'User pressed a notification, opening notifications tab',
{},
logger.DebugContext.notifications,
)
track('Notificatons:OpenApp')
store.me.notifications.refresh() // refresh notifications
queryClient.resetQueries({queryKey: RQKEY_NOTIFS()})
resetToTab('NotificationsTab') // open notifications tab
}
},
+15 -1
View File
@@ -1,3 +1,17 @@
import {QueryClient} from '@tanstack/react-query'
export const queryClient = new QueryClient()
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// NOTE
// refetchOnWindowFocus breaks some UIs (like feeds)
// so we NEVER want to enable this
// -prf
refetchOnWindowFocus: false,
// Structural sharing between responses makes it impossible to rely on
// "first seen" timestamps on objects to determine if they're fresh.
// Disable this optimization so that we can rely on "first seen" timestamps.
structuralSharing: false,
},
},
})
-19
View File
@@ -1,19 +0,0 @@
import {isAndroid} from 'platform/detection'
import {BackHandler} from 'react-native'
import {RootStoreModel} from 'state/index'
export function init(store: RootStoreModel) {
// only register back handler on android, otherwise it throws an error
if (isAndroid) {
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
return store.shell.closeAnyActiveElement()
},
)
return () => {
backHandler.remove()
}
}
return () => {}
}
+12
View File
@@ -13,3 +13,15 @@ export function makeProfileLink(
...segments,
].join('/')
}
export function makeCustomFeedLink(
did: string,
rkey: string,
...segments: string[]
) {
return [`/profile`, did, 'feed', rkey, ...segments].join('/')
}
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
return [`/profile`, did, 'lists', rkey, ...segments].join('/')
}
+4 -3
View File
@@ -5,8 +5,9 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack'
export type CommonNavigatorParams = {
NotFound: undefined
Lists: undefined
Moderation: undefined
ModerationMuteLists: undefined
ModerationModlists: undefined
ModerationMutedAccounts: undefined
ModerationBlockedAccounts: undefined
Settings: undefined
@@ -18,8 +19,8 @@ export type CommonNavigatorParams = {
PostThread: {name: string; rkey: string}
PostLikedBy: {name: string; rkey: string}
PostRepostedBy: {name: string; rkey: string}
CustomFeed: {name: string; rkey: string}
CustomFeedLikedBy: {name: string; rkey: string}
ProfileFeed: {name: string; rkey: string}
ProfileFeedLikedBy: {name: string; rkey: string}
Debug: undefined
Log: undefined
Support: undefined
+40 -46
View File
@@ -1,52 +1,46 @@
import {isNative, isWeb} from 'platform/detection'
import {FC} from 'react'
import * as Sentry from 'sentry-expo'
/**
* Importing these separately from `platform/detection` and `lib/app-info` to
* avoid future conflicts and/or circular deps
*/
// Sentry Initialization
import {Platform} from 'react-native'
import app from 'react-native-version-number'
import * as info from 'expo-updates'
import {init} from 'sentry-expo'
export const getRoutingInstrumentation = () => {
return new Sentry.Native.ReactNavigationInstrumentation() // initialize this in `onReady` prop of NavigationContainer
}
/**
* Matches the build profile `channel` props in `eas.json`
*/
const buildChannel = (info.channel || 'development') as
| 'development'
| 'preview'
| 'production'
Sentry.init({
/**
* 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
// @ts-ignore exists but not in types, see https://docs.sentry.io/platforms/react-native/configuration/options/#enableAutoPerformanceTracking
enableAutoPerformanceTracking: true, // Enable auto performance tracking
tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production
_experiments: {
// The sampling rate for profiling is relative to TracesSampleRate.
// In this case, we'll capture profiles for 50% of transactions.
profilesSampleRate: 0.5,
},
integrations: isNative
? [
new Sentry.Native.ReactNativeTracing({
shouldCreateSpanForRequest: url => {
// Do not create spans for outgoing requests to a `/logs` endpoint as it is too noisy due to expo
return !url.match(/\/logs$/)
},
routingInstrumentation: getRoutingInstrumentation(),
}),
]
: [], // no integrations for web, yet
enableInExpoDevelopment: true,
environment: buildChannel,
dist,
release,
})
// if web, use Browser client, otherwise use Native client
export function getSentryClient() {
if (isWeb) {
return Sentry.Browser
}
return Sentry.Native
}
// wrap root App component with Sentry for automatic touch event tracking and performance monitoring
export function withSentry(Component: FC) {
if (isWeb) {
return Component // .wrap is not required or available for web
}
const sentryClient = getSentryClient()
return sentryClient.wrap(Component)
}
+21 -6
View File
@@ -1,5 +1,5 @@
import {AtUri} from '@atproto/api'
import {PROD_SERVICE} from 'state/index'
import {PROD_SERVICE} from 'lib/constants'
import TLDs from 'tlds'
import psl from 'psl'
@@ -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
}
+1
View File
@@ -164,6 +164,7 @@ export const s = StyleSheet.create({
flexRow: {flexDirection: 'row'},
flexCol: {flexDirection: 'column'},
flex1: {flex: 1},
flexGrow1: {flexGrow: 1},
alignCenter: {alignItems: 'center'},
alignBaseline: {alignItems: 'baseline'},
+38
View File
@@ -0,0 +1,38 @@
import {useLanguagePrefs} from '#/state/preferences'
import {i18n} from '@lingui/core'
import {useEffect} from 'react'
import {messages as messagesEn} from './locales/en/messages'
import {messages as messagesHi} from './locales/hi/messages'
export const locales = {
en: 'English',
cs: 'Česky',
fr: 'Français',
hi: 'हिंदी',
es: 'Español',
}
export const defaultLocale = 'en'
/**
* We do a dynamic import of just the catalog that we need
* @param locale any locale string
*/
export async function dynamicActivate(locale: string) {
if (locale === 'en') {
i18n.loadAndActivate({locale, messages: messagesEn})
return
} else if (locale === 'hi') {
i18n.loadAndActivate({locale, messages: messagesHi})
return
} else {
i18n.loadAndActivate({locale, messages: messagesEn})
return
}
}
export async function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs()
useEffect(() => {
dynamicActivate(appLanguage)
}, [appLanguage])
}
+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
+99
View File
@@ -0,0 +1,99 @@
# Logger
Simple logger for Bluesky. Supports log levels, debug contexts, and separate
transports for production, dev, and test mode.
## At a Glance
```typescript
import { logger } from '#/logger'
logger.debug(message[, metadata, debugContext])
logger.info(message[, metadata])
logger.log(message[, metadata])
logger.warn(message[, metadata])
logger.error(error[, metadata])
```
#### Modes
The "modes" referred to here are inferred from the values exported from `#/env`.
Basically, the booleans `IS_DEV`, `IS_TEST`, and `IS_PROD`.
#### Log Levels
Log levels are used to filter which logs are either printed to the console
and/or sent to Sentry and other reporting services. To configure, set the
`EXPO_PUBLIC_LOG_LEVEL` environment variable in `.env` to one of `debug`,
`info`, `log`, `warn`, or `error`.
This variable should be `info` in production, and `debug` in dev. If it gets too
noisy in dev, simply set it to a higher level, such as `warn`.
## Usage
```typescript
import { logger } from '#/logger';
```
### `logger.error`
The `error` level is for... well, errors. These are sent to Sentry in production mode.
`error`, along with all log levels, supports an additional parameter, `metadata: Record<string, unknown>`. Use this to provide values to the [Sentry
breadcrumb](https://docs.sentry.io/platforms/react-native/enriching-events/breadcrumbs/#manual-breadcrumbs).
```typescript
try {
// some async code
} catch (e) {
logger.error(e, { ...metadata });
}
```
### `logger.warn`
Warnings will be sent to Sentry as a separate Issue with level `warning`, as
well as as breadcrumbs, with a severity level of `warning`
### `logger.log`
Logs with level `log` will be sent to Sentry as a separate Issue with level `log`, as
well as as breadcrumbs, with a severity level of `default`.
### `logger.info`
The `info` level should be used for information that would be helpful in a
tracing context, like Sentry. In production mode, `info` logs are sent
to Sentry as breadcrumbs, which decorate log levels above `info` such as `log`,
`warn`, and `error`.
### `logger.debug`
Debug level is really only intended for local development. Use this instead of
`console.log`.
```typescript
logger.debug(message, { ...metadata });
```
Inspired by [debug](https://www.npmjs.com/package/debug), when writing debug
logs, you can optionally pass a _context_, which can be then filtered when in
debug mode.
This value should be related to the feature, component, or screen
the code is running within, and **it should be defined in `#/logger/debugContext`**.
This way we know if a relevant context already exists, and we can trace all
active contexts in use in our app. This const enum is conveniently available on
the `logger` at `logger.DebugContext`.
For example, a debug log like this:
```typescript
logger.debug(message, {}, logger.DebugContext.composer);
```
Would be logged to the console in dev mode if `EXPO_PUBLIC_LOG_LEVEL=debug`, _or_ if you
pass a separate environment variable `LOG_DEBUG=composer`. This variable supports
multiple contexts using commas like `LOG_DEBUG=composer,profile`, and _automatically
sets the log level to `debug`, regardless of `EXPO_PUBLIC_LOG_LEVEL`._
+36
View File
@@ -0,0 +1,36 @@
import {expect, test} from '@jest/globals'
import {ConsoleTransportEntry, LogLevel} from '#/logger'
import {add, getEntries} from '#/logger/logDump'
test('works', () => {
const items: ConsoleTransportEntry[] = [
{
id: '1',
level: LogLevel.Debug,
message: 'hello',
metadata: {},
timestamp: Date.now(),
},
{
id: '2',
level: LogLevel.Debug,
message: 'hello',
metadata: {},
timestamp: Date.now(),
},
{
id: '3',
level: LogLevel.Debug,
message: 'hello',
metadata: {},
timestamp: Date.now(),
},
]
for (const item of items) {
add(item)
}
expect(getEntries()).toEqual(items.reverse())
})
+444
View File
@@ -0,0 +1,444 @@
import {nanoid} from 'nanoid/non-secure'
import {jest, describe, expect, test, beforeAll} from '@jest/globals'
import {Native as Sentry} from 'sentry-expo'
import {Logger, LogLevel, sentryTransport} from '#/logger'
jest.mock('#/env', () => ({
IS_TEST: true,
IS_DEV: false,
IS_PROD: false,
/*
* Forces debug mode for tests using the default logger. Most tests create
* their own logger instance.
*/
LOG_LEVEL: 'debug',
LOG_DEBUG: '',
}))
jest.mock('sentry-expo', () => ({
Native: {
addBreadcrumb: jest.fn(),
captureException: jest.fn(),
captureMessage: jest.fn(),
},
}))
beforeAll(() => {
jest.useFakeTimers()
})
describe('general functionality', () => {
test('default params', () => {
const logger = new Logger()
expect(logger.enabled).toBeFalsy()
expect(logger.level).toEqual(LogLevel.Debug) // mocked above
})
test('can override default params', () => {
const logger = new Logger({
enabled: true,
level: LogLevel.Info,
})
expect(logger.enabled).toBeTruthy()
expect(logger.level).toEqual(LogLevel.Info)
})
test('disabled logger does not report', () => {
const logger = new Logger({
enabled: false,
level: LogLevel.Debug,
})
const mockTransport = jest.fn()
logger.addTransport(mockTransport)
logger.debug('message')
expect(mockTransport).not.toHaveBeenCalled()
})
test('disablement', () => {
const logger = new Logger({
enabled: true,
level: LogLevel.Debug,
})
logger.disable()
const mockTransport = jest.fn()
logger.addTransport(mockTransport)
logger.debug('message')
expect(mockTransport).not.toHaveBeenCalled()
})
test('passing debug contexts automatically enables debug mode', () => {
const logger = new Logger({debug: 'specific'})
expect(logger.level).toEqual(LogLevel.Debug)
})
test('supports extra metadata', () => {
const timestamp = Date.now()
const logger = new Logger({enabled: true})
const mockTransport = jest.fn()
logger.addTransport(mockTransport)
const extra = {foo: true}
logger.warn('message', extra)
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Warn,
'message',
extra,
timestamp,
)
})
test('supports nullish/falsy metadata', () => {
const timestamp = Date.now()
const logger = new Logger({enabled: true})
const mockTransport = jest.fn()
const remove = logger.addTransport(mockTransport)
// @ts-expect-error testing the JS case
logger.warn('a', null)
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Warn,
'a',
{},
timestamp,
)
// @ts-expect-error testing the JS case
logger.warn('b', false)
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Warn,
'b',
{},
timestamp,
)
// @ts-expect-error testing the JS case
logger.warn('c', 0)
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Warn,
'c',
{},
timestamp,
)
remove()
logger.addTransport((level, message, metadata) => {
expect(typeof metadata).toEqual('object')
})
// @ts-expect-error testing the JS case
logger.warn('message', null)
})
test('sentryTransport', () => {
const message = 'message'
const timestamp = Date.now()
const sentryTimestamp = timestamp / 1000
sentryTransport(LogLevel.Debug, message, {}, timestamp)
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
message,
data: {},
type: 'default',
level: LogLevel.Debug,
timestamp: sentryTimestamp,
})
sentryTransport(
LogLevel.Info,
message,
{type: 'info', prop: true},
timestamp,
)
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
message,
data: {prop: true},
type: 'info',
level: LogLevel.Info,
timestamp: sentryTimestamp,
})
sentryTransport(LogLevel.Log, message, {}, timestamp)
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
message,
data: {},
type: 'default',
level: 'debug', // Sentry bug, log becomes debug
timestamp: sentryTimestamp,
})
expect(Sentry.captureMessage).toHaveBeenCalledWith(message, {
level: 'log',
tags: undefined,
extra: {},
})
sentryTransport(LogLevel.Warn, message, {}, timestamp)
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
message,
data: {},
type: 'default',
level: 'warning',
timestamp: sentryTimestamp,
})
expect(Sentry.captureMessage).toHaveBeenCalledWith(message, {
level: 'warning',
tags: undefined,
extra: {},
})
const e = new Error('error')
const tags = {
prop: 'prop',
}
sentryTransport(
LogLevel.Error,
e,
{
tags,
prop: true,
},
timestamp,
)
expect(Sentry.captureException).toHaveBeenCalledWith(e, {
tags,
extra: {
prop: true,
},
})
})
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})
const mockTransport = jest.fn()
const remove = logger.addTransport(mockTransport)
logger.warn('warn')
remove()
logger.warn('warn')
// only called once bc it was removed
expect(mockTransport).toHaveBeenNthCalledWith(
1,
LogLevel.Warn,
'warn',
{},
timestamp,
)
})
})
describe('debug contexts', () => {
const mockTransport = jest.fn()
test('specific', () => {
const timestamp = Date.now()
const message = nanoid()
const logger = new Logger({
enabled: true,
debug: 'specific',
})
logger.addTransport(mockTransport)
logger.debug(message, {}, 'specific')
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Debug,
message,
{},
timestamp,
)
})
test('namespaced', () => {
const timestamp = Date.now()
const message = nanoid()
const logger = new Logger({
enabled: true,
debug: 'namespace*',
})
logger.addTransport(mockTransport)
logger.debug(message, {}, 'namespace')
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Debug,
message,
{},
timestamp,
)
})
test('ignores inactive', () => {
const timestamp = Date.now()
const message = nanoid()
const logger = new Logger({
enabled: true,
debug: 'namespace:foo:*',
})
logger.addTransport(mockTransport)
logger.debug(message, {}, 'namespace:bar:baz')
expect(mockTransport).not.toHaveBeenCalledWith(
LogLevel.Debug,
message,
{},
timestamp,
)
})
})
describe('supports levels', () => {
test('debug', () => {
const timestamp = Date.now()
const logger = new Logger({
enabled: true,
level: LogLevel.Debug,
})
const message = nanoid()
const mockTransport = jest.fn()
logger.addTransport(mockTransport)
logger.debug(message)
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Debug,
message,
{},
timestamp,
)
logger.info(message)
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Info,
message,
{},
timestamp,
)
logger.warn(message)
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Warn,
message,
{},
timestamp,
)
const e = new Error(message)
logger.error(e)
expect(mockTransport).toHaveBeenCalledWith(LogLevel.Error, e, {}, timestamp)
})
test('info', () => {
const timestamp = Date.now()
const logger = new Logger({
enabled: true,
level: LogLevel.Info,
})
const message = nanoid()
const mockTransport = jest.fn()
logger.addTransport(mockTransport)
logger.debug(message)
expect(mockTransport).not.toHaveBeenCalled()
logger.info(message)
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Info,
message,
{},
timestamp,
)
})
test('warn', () => {
const timestamp = Date.now()
const logger = new Logger({
enabled: true,
level: LogLevel.Warn,
})
const message = nanoid()
const mockTransport = jest.fn()
logger.addTransport(mockTransport)
logger.debug(message)
expect(mockTransport).not.toHaveBeenCalled()
logger.info(message)
expect(mockTransport).not.toHaveBeenCalled()
logger.warn(message)
expect(mockTransport).toHaveBeenCalledWith(
LogLevel.Warn,
message,
{},
timestamp,
)
})
test('error', () => {
const timestamp = Date.now()
const logger = new Logger({
enabled: true,
level: LogLevel.Error,
})
const message = nanoid()
const mockTransport = jest.fn()
logger.addTransport(mockTransport)
logger.debug(message)
expect(mockTransport).not.toHaveBeenCalled()
logger.info(message)
expect(mockTransport).not.toHaveBeenCalled()
logger.warn(message)
expect(mockTransport).not.toHaveBeenCalled()
const e = new Error('original message')
logger.error(e)
expect(mockTransport).toHaveBeenCalledWith(LogLevel.Error, e, {}, timestamp)
})
})
+12
View File
@@ -0,0 +1,12 @@
/**
* *Do not import this directly.* Instead, use the shortcut reference `logger.DebugContext`.
*
* Add debug contexts here. Although convention typically calls for enums ito
* be capitalized, for parity with the `LOG_DEBUG` env var, please use all
* lowercase.
*/
export const DebugContext = {
// e.g. composer: 'composer'
session: 'session',
notifications: 'notifications',
} as const
+300
View File
@@ -0,0 +1,300 @@
import format from 'date-fns/format'
import {nanoid} from 'nanoid/non-secure'
import {Sentry} from '#/logger/sentry'
import * as env from '#/env'
import {DebugContext} from '#/logger/debugContext'
import {add} from '#/logger/logDump'
export enum LogLevel {
Debug = 'debug',
Info = 'info',
Log = 'log',
Warn = 'warn',
Error = 'error',
}
type Transport = (
level: LogLevel,
message: string | Error,
metadata: Metadata,
timestamp: number,
) => void
/**
* A union of some of Sentry's breadcrumb properties as well as Sentry's
* `captureException` parameter, `CaptureContext`.
*/
type Metadata = {
/**
* Applied as Sentry breadcrumb types. Defaults to `default`.
*
* @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
*/
type?:
| 'default'
| 'debug'
| 'error'
| 'navigation'
| 'http'
| 'info'
| 'query'
| 'transaction'
| 'ui'
| 'user'
/**
* Passed through to `Sentry.captureException`
*
* @see https://github.com/getsentry/sentry-javascript/blob/903addf9a1a1534a6cb2ba3143654b918a86f6dd/packages/types/src/misc.ts#L65
*/
tags?: {
[key: string]:
| number
| string
| boolean
| bigint
| symbol
| null
| undefined
}
/**
* Any additional data, passed through to Sentry as `extra` param on
* exceptions, or the `data` param on breadcrumbs.
*/
[key: string]: unknown
} & Parameters<typeof Sentry.captureException>[1]
export type ConsoleTransportEntry = {
id: string
timestamp: number
level: LogLevel
message: string | Error
metadata: Metadata
}
const enabledLogLevels: {
[key in LogLevel]: LogLevel[]
} = {
[LogLevel.Debug]: [
LogLevel.Debug,
LogLevel.Info,
LogLevel.Log,
LogLevel.Warn,
LogLevel.Error,
],
[LogLevel.Info]: [LogLevel.Info, LogLevel.Log, LogLevel.Warn, LogLevel.Error],
[LogLevel.Log]: [LogLevel.Log, LogLevel.Warn, LogLevel.Error],
[LogLevel.Warn]: [LogLevel.Warn, LogLevel.Error],
[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
*/
export const consoleTransport: Transport = (
level,
message,
metadata,
timestamp,
) => {
const extra = Object.keys(metadata).length
? // don't prepareMetadata here, in dev we want the stack trace
' ' + JSON.stringify(metadata, null, ' ')
: ''
const log = {
[LogLevel.Debug]: console.debug,
[LogLevel.Info]: console.info,
[LogLevel.Log]: console.log,
[LogLevel.Warn]: console.warn,
[LogLevel.Error]: console.error,
}[level]
log(`${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`)
}
export const sentryTransport: Transport = (
level,
message,
{type, tags, ...metadata},
timestamp,
) => {
const meta = prepareMetadata(metadata)
/**
* If a string, report a breadcrumb
*/
if (typeof message === 'string') {
const severity = (
{
[LogLevel.Debug]: 'debug',
[LogLevel.Info]: 'info',
[LogLevel.Log]: 'log', // Sentry value here is undefined
[LogLevel.Warn]: 'warning',
[LogLevel.Error]: 'error',
} as const
)[level]
Sentry.addBreadcrumb({
message,
data: meta,
type: type || 'default',
level: severity,
timestamp: timestamp / 1000, // Sentry expects seconds
})
/**
* Send all higher levels with `captureMessage`, with appropriate severity
* level
*/
if (level === 'error' || level === 'warn' || level === 'log') {
const messageLevel = ({
[LogLevel.Log]: 'log',
[LogLevel.Warn]: 'warning',
[LogLevel.Error]: 'error',
}[level] || 'log') as Sentry.Breadcrumb['level']
Sentry.captureMessage(message, {
level: messageLevel,
tags,
extra: meta,
})
}
} else {
/**
* It's otherwise an Error and should be reported with captureException
*/
Sentry.captureException(message, {
tags,
extra: meta,
})
}
}
/**
* Main class. Defaults are provided in the constructor so that subclasses are
* technically possible, if we need to go that route in the future.
*/
export class Logger {
LogLevel = LogLevel
DebugContext = DebugContext
enabled: boolean
level: LogLevel
transports: Transport[] = []
protected debugContextRegexes: RegExp[] = []
constructor({
enabled = !env.IS_TEST,
level = env.LOG_LEVEL as LogLevel,
debug = env.LOG_DEBUG || '',
}: {
enabled?: boolean
level?: LogLevel
debug?: string
} = {}) {
this.enabled = enabled !== false
this.level = debug ? LogLevel.Debug : level ?? LogLevel.Info // default to info
this.debugContextRegexes = (debug || '').split(',').map(context => {
return new RegExp(context.replace(/[^\w:*]/, '').replace(/\*/g, '.*'))
})
}
debug(message: string, metadata: Metadata = {}, context?: string) {
if (context && !this.debugContextRegexes.find(reg => reg.test(context)))
return
this.transport(LogLevel.Debug, message, metadata)
}
info(message: string, metadata: Metadata = {}) {
this.transport(LogLevel.Info, message, metadata)
}
log(message: string, metadata: Metadata = {}) {
this.transport(LogLevel.Log, message, metadata)
}
warn(message: string, metadata: Metadata = {}) {
this.transport(LogLevel.Warn, message, metadata)
}
error(error: Error | string, metadata: Metadata = {}) {
this.transport(LogLevel.Error, error, metadata)
}
addTransport(transport: Transport) {
this.transports.push(transport)
return () => {
this.transports.splice(this.transports.indexOf(transport), 1)
}
}
disable() {
this.enabled = false
}
enable() {
this.enabled = true
}
protected transport(
level: LogLevel,
message: string | Error,
metadata: Metadata = {},
) {
if (!this.enabled) return
if (!enabledLogLevels[this.level].includes(level)) return
const timestamp = Date.now()
const meta = metadata || {}
for (const transport of this.transports) {
transport(level, message, meta, timestamp)
}
add({
id: nanoid(),
timestamp,
level,
message,
metadata: meta,
})
}
}
/**
* Logger instance. See `@/logger/README` for docs.
*
* Basic usage:
*
* `logger.debug(message[, metadata, debugContext])`
* `logger.info(message[, metadata])`
* `logger.warn(message[, metadata])`
* `logger.error(error[, metadata])`
* `logger.disable()`
* `logger.enable()`
*/
export const logger = new Logger()
if (env.IS_DEV && !env.IS_TEST) {
logger.addTransport(consoleTransport)
/**
* Comment this out to disable Sentry transport in dev
*/
logger.addTransport(sentryTransport)
} else if (env.IS_PROD) {
logger.addTransport(sentryTransport)
}
+12
View File
@@ -0,0 +1,12 @@
import type {ConsoleTransportEntry} from '#/logger'
let entries: ConsoleTransportEntry[] = []
export function add(entry: ConsoleTransportEntry) {
entries.unshift(entry)
entries = entries.slice(0, 50)
}
export function getEntries() {
return entries
}
+1
View File
@@ -0,0 +1 @@
export {Native as Sentry} from 'sentry-expo'
+1
View File
@@ -0,0 +1 @@
export {Browser as Sentry} from 'sentry-expo'
+4 -3
View File
@@ -7,8 +7,9 @@ export const router = new Router({
Notifications: '/notifications',
Settings: '/settings',
LanguageSettings: '/settings/language',
Lists: '/lists',
Moderation: '/moderation',
ModerationMuteLists: '/moderation/mute-lists',
ModerationModlists: '/moderation/modlists',
ModerationMutedAccounts: '/moderation/muted-accounts',
ModerationBlockedAccounts: '/moderation/blocked-accounts',
Profile: '/profile/:name',
@@ -18,8 +19,8 @@ export const router = new Router({
PostThread: '/profile/:name/post/:rkey',
PostLikedBy: '/profile/:name/post/:rkey/liked-by',
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
CustomFeed: '/profile/:name/feed/:rkey',
CustomFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
ProfileFeed: '/profile/:name/feed/:rkey',
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
Debug: '/sys/debug',
Log: '/sys/log',
AppPasswords: '/settings/app-passwords',
+111
View File
@@ -0,0 +1,111 @@
import {useEffect, useState, useMemo, useCallback} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyFeedDefs} from '@atproto/api'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {Shadow, castAsShadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
export interface PostShadow {
likeUri: string | undefined
likeCount: number | undefined
repostUri: string | undefined
repostCount: number | undefined
isDeleted: boolean
}
export const POST_TOMBSTONE = Symbol('PostTombstone')
interface CacheEntry {
ts: number
value: PostShadow
}
const firstSeenMap = new WeakMap<AppBskyFeedDefs.PostView, number>()
function getFirstSeenTS(post: AppBskyFeedDefs.PostView): number {
let timeStamp = firstSeenMap.get(post)
if (timeStamp !== undefined) {
return timeStamp
}
timeStamp = Date.now()
firstSeenMap.set(post, timeStamp)
return timeStamp
}
export function usePostShadow(
post: AppBskyFeedDefs.PostView,
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
const postSeenTS = getFirstSeenTS(post)
const [state, setState] = useState<CacheEntry>(() => ({
ts: postSeenTS,
value: fromPost(post),
}))
const [prevPost, setPrevPost] = useState(post)
if (post !== prevPost) {
// if we got a new prop, assume it's fresher
// than whatever shadow state we accumulated
setPrevPost(post)
setState({
ts: postSeenTS,
value: fromPost(post),
})
}
const onUpdate = useCallback(
(value: Partial<PostShadow>) => {
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
},
[setState],
)
// react to shadow updates
useEffect(() => {
emitter.addListener(post.uri, onUpdate)
return () => {
emitter.removeListener(post.uri, onUpdate)
}
}, [post.uri, onUpdate])
return useMemo(() => {
return state.ts > postSeenTS
? mergeShadow(post, state.value)
: castAsShadow(post)
}, [post, state, postSeenTS])
}
export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
batchedUpdates(() => {
emitter.emit(uri, value)
})
}
function fromPost(post: AppBskyFeedDefs.PostView): PostShadow {
return {
likeUri: post.viewer?.like,
likeCount: post.likeCount,
repostUri: post.viewer?.repost,
repostCount: post.repostCount,
isDeleted: false,
}
}
function mergeShadow(
post: AppBskyFeedDefs.PostView,
shadow: PostShadow,
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
if (shadow.isDeleted) {
return POST_TOMBSTONE
}
return castAsShadow({
...post,
likeCount: shadow.likeCount,
repostCount: shadow.repostCount,
viewer: {
...(post.viewer || {}),
like: shadow.likeUri,
repost: shadow.repostUri,
},
})
}
+107
View File
@@ -0,0 +1,107 @@
import {useEffect, useState, useMemo, useCallback} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyActorDefs} from '@atproto/api'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {Shadow, castAsShadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
export interface ProfileShadow {
followingUri: string | undefined
muted: boolean | undefined
blockingUri: string | undefined
}
interface CacheEntry {
ts: number
value: ProfileShadow
}
type ProfileView =
| AppBskyActorDefs.ProfileView
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed
const firstSeenMap = new WeakMap<ProfileView, number>()
function getFirstSeenTS(profile: ProfileView): number {
let timeStamp = firstSeenMap.get(profile)
if (timeStamp !== undefined) {
return timeStamp
}
timeStamp = Date.now()
firstSeenMap.set(profile, timeStamp)
return timeStamp
}
export function useProfileShadow(profile: ProfileView): Shadow<ProfileView> {
const profileSeenTS = getFirstSeenTS(profile)
const [state, setState] = useState<CacheEntry>(() => ({
ts: profileSeenTS,
value: fromProfile(profile),
}))
const [prevProfile, setPrevProfile] = useState(profile)
if (profile !== prevProfile) {
// if we got a new prop, assume it's fresher
// than whatever shadow state we accumulated
setPrevProfile(profile)
setState({
ts: profileSeenTS,
value: fromProfile(profile),
})
}
const onUpdate = useCallback(
(value: Partial<ProfileShadow>) => {
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
},
[setState],
)
// react to shadow updates
useEffect(() => {
emitter.addListener(profile.did, onUpdate)
return () => {
emitter.removeListener(profile.did, onUpdate)
}
}, [profile.did, onUpdate])
return useMemo(() => {
return state.ts > profileSeenTS
? mergeShadow(profile, state.value)
: castAsShadow(profile)
}, [profile, state, profileSeenTS])
}
export function updateProfileShadow(
uri: string,
value: Partial<ProfileShadow>,
) {
batchedUpdates(() => {
emitter.emit(uri, value)
})
}
function fromProfile(profile: ProfileView): ProfileShadow {
return {
followingUri: profile.viewer?.following,
muted: profile.viewer?.muted,
blockingUri: profile.viewer?.blocking,
}
}
function mergeShadow(
profile: ProfileView,
shadow: ProfileShadow,
): Shadow<ProfileView> {
return castAsShadow({
...profile,
viewer: {
...(profile.viewer || {}),
following: shadow.followingUri,
muted: shadow.muted,
blocking: shadow.blockingUri,
},
})
}
+7
View File
@@ -0,0 +1,7 @@
// This isn't a real property, but it prevents T being compatible with Shadow<T>.
declare const shadowTag: unique symbol
export type Shadow<T> = T & {[shadowTag]: true}
export function castAsShadow<T>(value: T): Shadow<T> {
return value as any as Shadow<T>
}
+38
View File
@@ -0,0 +1,38 @@
import EventEmitter from 'eventemitter3'
import {BskyAgent} from '@atproto/api'
import {SessionAccount} from './session'
type UnlistenFn = () => void
const emitter = new EventEmitter()
// a "soft reset" typically means scrolling to top and loading latest
// but it can depend on the screen
export function emitSoftReset() {
emitter.emit('soft-reset')
}
export function listenSoftReset(fn: () => void): UnlistenFn {
emitter.on('soft-reset', fn)
return () => emitter.off('soft-reset', fn)
}
export function emitSessionLoaded(
sessionAccount: SessionAccount,
agent: BskyAgent,
) {
emitter.emit('session-loaded', sessionAccount, agent)
}
export function listenSessionLoaded(
fn: (sessionAccount: SessionAccount, agent: BskyAgent) => void,
): UnlistenFn {
emitter.on('session-loaded', fn)
return () => emitter.off('session-loaded', fn)
}
export function emitSessionDropped() {
emitter.emit('session-dropped')
}
export function listenSessionDropped(fn: () => void): UnlistenFn {
emitter.on('session-dropped', fn)
return () => emitter.off('session-dropped', fn)
}
-53
View File
@@ -1,53 +0,0 @@
import {autorun} from 'mobx'
import {AppState, Platform} from 'react-native'
import {BskyAgent} from '@atproto/api'
import {RootStoreModel} from './models/root-store'
import * as apiPolyfill from 'lib/api/api-polyfill'
import * as storage from 'lib/storage'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
export const STAGING_SERVICE = 'https://staging.bsky.dev'
export const PROD_SERVICE = 'https://bsky.social'
export const DEFAULT_SERVICE = PROD_SERVICE
const ROOT_STATE_STORAGE_KEY = 'root'
const STATE_FETCH_INTERVAL = 15e3
export async function setupState(serviceUri = DEFAULT_SERVICE) {
let rootStore: RootStoreModel
let data: any
apiPolyfill.doPolyfill()
rootStore = new RootStoreModel(new BskyAgent({service: serviceUri}))
try {
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
rootStore.hydrate(data)
} catch (e: any) {
rootStore.log.error('Failed to load state from storage', e)
}
rootStore.attemptSessionResumption()
// track changes & save to storage
autorun(() => {
const snapshot = rootStore.serialize()
storage.save(ROOT_STATE_STORAGE_KEY, snapshot)
})
// periodic state fetch
setInterval(() => {
// NOTE
// this must ONLY occur when the app is active, as the bg-fetch handler
// will wake up the thread and cause this interval to fire, which in
// turn schedules a bunch of work at a poor time
// -prf
if (AppState.currentState === 'active') {
rootStore.updateSessionState()
}
}, STATE_FETCH_INTERVAL)
return rootStore
}
export {useStores, RootStoreModel, RootStoreProvider} from './models/root-store'

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