Compare commits

...

195 Commits

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

* Remove unnecessary state

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

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

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

* Fix invalidation of notifs

* Move spinner below header

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

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

* Rm unused

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

* Track both current and pending actions

* Skip unnecessary actions

* Commit last confirmed state to shadow

* Thread state through actions over time

* Fix the logic to skip redundant mutations

* Track status

* Extract an abstraction

* Fix standalone mutations

* Add types

* Move to another file

* Return stable function

* Clean up

* Use queue for muting

* Use queue for blocking

* Convert other follow buttons

* Don't export non-queue mutations

* Properly handle canceled tasks

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

* Update modal to use session instead of store

* Move image sizes cache off store

* Update settings to no longer use store

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

* Remove deprecated resolveName()

* Delete deprecated link-metas cache

* Delete deprecated posts cache

* Delete all remaining mobx models, including the root store

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

* Add translations

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

* Replace delete account logic

* Replace me model in bottom bar web

* Replace me model in bottom bar

* Replace me model in reply prompt

* Better fallback

* Fix reference

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

* Better telemetry

* Remove unused logic

* Remove caching

* Add error message

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

* Replace in drawer

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

* Add close lightbox

* Switch to hooks

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

* Cleanup

* Remove my-follows cache

* Replace moderationOpts in ProfileCard comp

* Replace moderationOpts in FeedSlice

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

* Remove post-thread-item

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

* Move soft-reset event out of the shell model

* Update soft-reset listener on new search page

* Implement session-loaded and session-dropped events

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

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

* Mobile search

* Dedupe suggestions

* Clean up and reorg

* Cleanup

* Cleanup

* Use Pager

* Delete unused code

* Fix conflicts

* Remove search ui model

* Soft reset

* Fix scrollable results, remove observer

* Use correct ScrollView

* Clean up layout

---------

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

* Persist autocomplete results while they're in progress

* Commit lockfile

* Use ReturnType helper

---------

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

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

* Port user follows to RQ

* Start porting FollowButton to RQ

* Fix RQ key

* Check pending

* Fix shadow and pending states

* Rm unused

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

* Remove log

* Add actor typeahead handling

* Trim down desktop search styles and hooks

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

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

* Use queryClient

* Fix type

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

* Rework composer to use RQ

---------

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

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

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

* Fix error state, track call

* Remove todo

* Use flatmap

* Add additional try catch

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

* Update ProfileCard to use the profile shadow cache and useModerationOpts

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

* Fix keys

* Add missing behaviors to post tabs

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

* Replace old FeedSourceCard

* Clean up CustomFeedEmbed

* Remove discover feeds model

* Refactor ProfileFeed screen

* Remove useCustomFeed

* Delete some unused models

* Rip out more prefs

* Factor out treeView from thread comp

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

* Update profile editor

* Add dynamic likes tab

* Add dynamic feeds and lists tabs

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

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

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

* Cleanup

---------

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

* Use queryClient to fetch

* Replace useHomeTabs and useDesktopRightNavItems

* Fix type error

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

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

* Add useModerationOpts hook

* Refactor notifications to use react-query

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

* Implement unread notification tracking

* Add moderation filtering to notifications

* Handle native/push notifications

* Remove dead code

---------

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

* Remove unused feed UI models

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

* Delete old lists-list model

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

* Update CreateOrEditList modal

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

* Update ListAddUserModal to use new queries, change to ListAddRemoveUsersModal

* Update UserAddRemoveLists modal

* Remove old TODO

* Fix indent, autocomplete query

* Add a todo

---------

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

* Remove unused

* Clean up labels, migrate getModerationOpts

* Add birth date handling

* Migrate feed prefs

* Migrate thread view prefs

* Migrate homeFeed to use existing key name

* Fix up saved feeds in response, no impl yet

* Migrate saved feeds to new hooks

* Clean up more of preferences

* Fix PreferencesThreads load state

* Fix modal dismissal

* Small spacing fix

---------

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

* Introduce post-feed react query

* Add feed refresh behaviors

* Only fetch feeds of visible pages

* Implement polling for latest on feeds

* Add moderation filtering to slices

* Handle block errors

* Update feed error messages

* Remove old models

* Replace simple-feed option with disable-tuner option

* Add missing useMemo

* Implement the mergefeed and fixes to polling

* Correctly handle failed load more state

* Improve error and empty state behaviors

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

* Force Feed to always take handlers

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

This reverts commit ecebb78e40.

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

* Update PostThread components to use new queries

* Move from normalized cache to shadow cache model

* Merge post shadow into the post automatically

* Remove dead code

* Remove old temporary session

* Fix: set agent on session creation

* Temporarily double-login

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

* TODOs

* Fix recursion

* Couple more things

* Add back resume session concept

* Handle ready

* Cleanup of initial loading states

* Handle init failure

* Cleanup

* Remove account

* Add updateCurrentAccount

* Remove log

* Cleanup

* Integrate removeAccount

* Add hasSession

* Add to App.native, harden migration

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

* Make shell mode a float

* Translate based on the gesture

* Track header and footer heights

* Add web support

* Fix types and cleanup

* Add back isScrolled logic

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

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

* setup dynamic locale activation and async loading

* first pass of automated replacement of text messages

* add some more documentaton

* fix nits

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

* make accessibilityLabel localized

* compile and extract new messages

* fix merge conflicts

* fix eslint warning

* change instructions from sending email to opening PR

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

* Remove unused code

* Change shell mode without re-renders

* Adjust "write your reply" for mode
2023-11-09 00:25:27 +00:00
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
dan f9944b55e2 Simplify loading emoji data (#1790) 2023-11-01 17:49:07 +00:00
dan 9fb20517ae Remove unused Intl.Segmenter polyfill (#1789) 2023-11-01 16:52:34 +00:00
Eric Bailey 4bf1719092 Merge pull request #1788 from bluesky-social/eric/app-899-remove-icons-from-startup-code
Revise icons startup code
2023-11-01 10:38:37 -05:00
dan 85c26fb5a8 Instrument module init in DEV (#1787) 2023-11-01 15:31:33 +00:00
Eric Bailey b42806a6e0 Revise icons startup code 2023-11-01 10:15:37 -05:00
Samiksha Mishra adb5ce2961 Updated the mistake (#1777)
Corrected the typo error
2023-10-31 08:39:35 -07:00
Tarık b5f36dc7a3 UI/UX improvements in Edit profile screen (#1767)
* Remove cropped area in Edit profile screen iOS

* Hide cancel button when saving changes in EditProfile

* Disable fadeOut animation cancel button for web in EditProfile screen

Since react-native-reanimated is not configured for web support (https://docs.swmansion.com/react-native-reanimated/docs/2.x/fundamentals/web-support/), we are enabling fade out animation for iOS and android solely
2023-10-30 21:01:25 -07:00
Igor Shevchenko b2d4771b5f chore: fix typos in testing.md (#1764) 2023-10-30 20:40:30 -07:00
Eric Bailey ddf0a1cced Filter non-self threads from profile view (#1757) 2023-10-30 20:40:06 -07:00
Paul Frazee 40752982da Merge branch 'main' of https://github.com/haideralipunjabi/social-app into main 2023-10-30 19:26:23 -07:00
Leo Lamprecht 68c809b09a Use more beautiful title separator (#1728) 2023-10-30 19:25:19 -07:00
Pouria Delfanazari 21f7a2a247 Allow back navigation to home on profile header (#1717)
Navigate back to home if there is no navigation history.
2023-10-30 19:23:41 -07:00
Paul Frazee 97ce9a73d2 Merge branch 'main' of github.com:bluesky-social/social-app into main 2023-10-30 19:18:34 -07:00
Paul Frazee 33ea9ccef7 Merge branch 'Tanza3D-improve-post-button' into main 2023-10-30 19:18:24 -07:00
Paul Frazee 33a87ca22e Yep, one pixel 2023-10-30 19:18:02 -07:00
dan ef1d6ee490 Log time to first paint (#1775)
* Log time to first paint

* Fix TS
2023-10-31 02:07:14 +00:00
Paul Frazee 1eb623ee2f Merge branch 'improve-post-button' of https://github.com/Tanza3D/social-app into Tanza3D-improve-post-button 2023-10-30 18:54:29 -07:00
Paul Frazee d5c7b8ceb8 Merge branch 'main' of github.com:bluesky-social/social-app into main 2023-10-30 18:07:33 -07:00
Paul Frazee bae429202a Merge branch 'liby-fix/step2-waitlist-link-accessibility' into main 2023-10-30 18:06:58 -07:00
Paul Frazee 0747193977 Fix a11y prop 2023-10-30 18:06:31 -07:00
dan 4a4106ba45 Don't inline-require Babel runtime helpers (#1774) 2023-10-31 00:59:00 +00:00
Paul Frazee 2577cbf252 Merge branch 'fix/step2-waitlist-link-accessibility' of https://github.com/liby/social-app into liby-fix/step2-waitlist-link-accessibility 2023-10-30 17:50:44 -07:00
Paul Frazee 9f33badfff Merge branch 'main' of github.com:bluesky-social/social-app into main 2023-10-30 17:50:31 -07:00
Paul Frazee e18e7ce34a Merge branch 'liby-bugfix/desktop-web-text-link-media-query-issue' into main 2023-10-30 17:45:07 -07:00
Paul Frazee 9826e7c7b9 Rename component to match new behavior 2023-10-30 17:44:58 -07:00
dan 515c9d5529 Don't compile Hermes-supported features with Babel (#1773)
* Don't transpile Hermes-supported features

* Disable the object spread plugin

We're using Hermes, and Hermes supports object spread.
We can remove this patch when we upgrade Expo because it'll be conditional there.
2023-10-31 00:23:01 +00:00
Paul Frazee 871ded193c Merge branch 'bugfix/desktop-web-text-link-media-query-issue' of https://github.com/liby/social-app into liby-bugfix/desktop-web-text-link-media-query-issue 2023-10-30 17:11:17 -07:00
Paul Frazee e1dcedd87e Merge branch 'elijah-wright-patch-1' into main 2023-10-30 17:08:15 -07:00
Paul Frazee 1acf4969c5 Fix lint 2023-10-30 17:08:07 -07:00
Paul Frazee dbbd71c6d0 Merge branch 'patch-1' of https://github.com/elijah-wright/social-app into elijah-wright-patch-1 2023-10-30 17:03:34 -07:00
dan 84ee64025f Let Metro handle import/export instead of Babel (#1772)
* Let Metro handle import/export instead of Babel

* Keep using Babel for ESM->CJS in tests
2023-10-30 21:54:23 +00:00
Paul Frazee 6c11c0b81d 1.55 2023-10-30 09:58:58 -07:00
dan 7ebf1ed371 Initialize screen modules lazily (#1763)
* Initialize screen modules lazily

* Use getComponent (thanks @ecreeth for the tip)
2023-10-27 19:06:28 +01:00
dan 53afafb04f Remove redundant linking listeners (#1760) 2023-10-26 21:05:48 -07:00
dan 3e5a64b454 Fix white flash on startup (#1759) 2023-10-27 04:06:40 +01:00
dan 46f3265936 Enable inline requires (#1756) 2023-10-26 17:54:15 -07:00
Paul Frazee a1a61ef2e5 Merge branch 'main' of github.com:bluesky-social/social-app into main 2023-10-26 10:40:35 -07:00
Ansh 927cee18d9 Update Analytics (#1743) 2023-10-26 08:37:14 -07:00
Haider Ali Punjabi 7fa0708e0e Added show more for long posts 2023-10-26 19:38:39 +05:30
Ansh c13b6946ba 1721 Update support link (#1722)
* change support page copy

* fix grammar
2023-10-25 10:00:46 -07:00
Ansh ee80cae75f add scroll indicator on web to profile suggested follows card (#1724) 2023-10-25 09:54:52 -07:00
Ansh 1781b3aab7 allow enter key to trigger waitlist signup (#1723) 2023-10-25 09:54:04 -07:00
elliot 3426b24f67 Fixed CropImage modal aspect ratio buttons visibility in dark mode (#1730) 2023-10-25 09:41:56 -07:00
dan 4a4be1de1a Remove MobX from Link (#1745)
* Remove MobX from Link

* Keep memo() though

We previously had observer() apply it automatically, so re-add it
2023-10-25 08:47:21 -07:00
dan 32fbb9dba7 Simplify the avatar component (#1744)
* Copypaste UserAvatar to EditableUserAvatar

* Swap usages with onSelectNewAvatar to EditableUserAvatar

* Split prop types into UserAvatarProps and EditableUserAvatarProps

* Remove dead branches from EditableUserAvatar

* Remove dead branches from UserAvatar
2023-10-25 04:57:34 +01:00
dan c6e28f88e5 Fix four gallery images for Firefox (#1710) 2023-10-18 15:08:12 -04:00
Tanza3D db0de651ba Fix wonky comment 2023-10-18 14:14:45 +01:00
Tanza3D 6848ade046 Shrink to 16 again 2023-10-18 13:53:55 +01:00
Tanza3D de94acb6c8 Improve New Post button 2023-10-18 13:53:00 +01:00
Bryan Lee 78b374b43d Improved accessibility and interaction for waitlist Link in Step2 component 2023-10-15 06:26:56 +08:00
Paul Frazee 01e25c912c 1.54 2023-10-14 10:32:11 -07:00
Elijah Wright c39352f859 readd hint 2023-10-08 14:30:57 -07:00
Elijah Wright 0122cdb0b6 fix accessibility in AutoSizedImage 2023-10-08 14:29:20 -07:00
Bryan Lee 2ca673d7f5 Replace isDesktop with isWeb in DesktopWebTextLink component
- Replaced `useWebMediaQueries().isDesktop` with `isWeb` in `DesktopWebTextLink` component to fix inconsistent behavior due to media query changes.
- `isWeb` doesn't rely on media queries, offering stable judgement and avoiding fluctuation when adjusting the browser window.
2023-10-04 21:47:19 +08:00
426 changed files with 30892 additions and 18987 deletions
+2
View File
@@ -1 +1,3 @@
SENTRY_AUTH_TOKEN=
EXPO_PUBLIC_LOG_LEVEL=debug
EXPO_PUBLIC_LOG_DEBUG=
+1 -1
View File
@@ -23,7 +23,7 @@ The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentrali
- [Protocol Specifications](https://atproto.com/specs/atp)
- [Blogpost on self-authenticating data structures](https://blueskyweb.xyz/blog/3-6-2022-a-self-authenticating-social-protocol)
The Bluesky Social application encompases a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`.
The Bluesky Social application encompasses a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`.
## Contributions
+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(),
},
}))
+3 -3
View File
@@ -6,7 +6,7 @@ module.exports = function () {
slug: 'bluesky',
scheme: 'bluesky',
owner: 'blueskysocial',
version: '1.53.0',
version: '1.56.0',
runtimeVersion: {
policy: 'appVersion',
},
@@ -19,7 +19,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
ios: {
buildNumber: '1',
buildNumber: '3',
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
config: {
@@ -43,7 +43,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
android: {
versionCode: 42,
versionCode: 46,
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff',
+19 -1
View File
@@ -1,7 +1,23 @@
module.exports = function (api) {
api.cache(true)
const isTestEnv = process.env.NODE_ENV === 'test'
return {
presets: ['babel-preset-expo'],
presets: [
[
'babel-preset-expo',
{
lazyImports: true,
native: {
// We should be able to remove this after upgrading Expo
// to a version that includes https://github.com/expo/expo/pull/24672.
unstable_transformProfile: 'hermes-stable',
// Disable ESM -> CJS compilation because Metro takes care of it.
// However, we need it in Jest tests since those run without Metro.
disableImportExportTransform: !isTestEnv,
},
},
],
],
plugins: [
[
'module:react-native-dotenv',
@@ -21,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',
@@ -28,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>;
}
```
+2 -2
View File
@@ -1,13 +1,13 @@
# Testing instructions
### Using Maestro E2E tests
1. Install Maestro by following [these instuctions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
1. Install Maestro by following [these instructions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
2. You can write Maestro tests in `__e2e__/maestro` directory by creating a new `.yaml` file or by modifying an existing one.
3. You can also use [Maestro Studio](https://maestro.mobile.dev/getting-started/maestro-studio) which automatically generates commands by recording your actions on the app. Therefore, you can create realistic tests without having to manually write any code. Use the `maestro studio` command to start recording your actions.
### Using Flashlight for Performance Testing
1. Make sure Maestro is installed (optional: only for auomated testing) by following the instructions above
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/)
3. The simplest way to get started is by running `yarn perf:measure` which will run a live preview of the performance test results. You can [see a demo here](https://github.com/bamlab/flashlight/assets/4534323/4038a342-f145-4c3b-8cde-17949bf52612)
4. The `yarn perf:test:measure` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml` and give the results in `.perf/results.json` which can be viewed by running `yarn:perf:results`
+4 -4
View File
@@ -9,7 +9,7 @@
"distribution": "internal",
"ios": {
"simulator": true,
"resourceClass": "large"
"resourceClass": "m-large"
},
"channel": "development"
},
@@ -17,20 +17,20 @@
"developmentClient": true,
"distribution": "internal",
"ios": {
"resourceClass": "large"
"resourceClass": "m-large"
},
"channel": "development"
},
"preview": {
"distribution": "internal",
"ios": {
"resourceClass": "large"
"resourceClass": "m-large"
},
"channel": "preview"
},
"production": {
"ios": {
"resourceClass": "large"
"resourceClass": "m-large"
},
"channel": "production"
},
+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',
}
+18
View File
@@ -1,7 +1,25 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const {getDefaultConfig} = require('expo/metro-config')
const cfg = getDefaultConfig(__dirname)
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
: cfg.resolver.sourceExts
cfg.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
inlineRequires: true,
nonInlinedRequires: [
// We can remove this option and rely on the default after
// https://github.com/facebook/metro/pull/1126 is released.
'React',
'react',
'react/jsx-dev-runtime',
'react/jsx-runtime',
'react-native',
],
},
})
module.exports = cfg
+27 -17
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.53.0",
"version": "1.56.0",
"private": true,
"scripts": {
"prepare": "is-ci || husky install",
@@ -13,25 +13,27 @@
"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",
"build:apk": "eas build -p android --profile dev-android-apk",
"intl:extract": "lingui extract",
"intl:compile": "lingui compile"
},
"dependencies": {
"@atproto/api": "^0.6.20",
"@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 +44,7 @@
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.0",
"@gorhom/bottom-sheet": "^4.5.1",
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.6.4",
"@miblanchard/react-native-slider": "^2.3.1",
"@react-native-async-storage/async-storage": "1.18.2",
@@ -60,7 +63,7 @@
"@segment/analytics-react-native": "^2.10.1",
"@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.10.0",
"@tanstack/react-query": "^4.33.0",
"@tanstack/react-query": "^5.8.1",
"@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220",
"@tiptap/extension-hard-break": "^2.0.3",
@@ -80,6 +83,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",
@@ -102,7 +106,6 @@
"expo-system-ui": "~2.4.0",
"expo-updates": "~0.18.12",
"fast-text-encoding": "^1.0.6",
"graphemer": "^1.4.0",
"history": "^5.3.0",
"js-sha256": "^0.9.0",
"lande": "^1.0.10",
@@ -119,6 +122,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",
@@ -129,7 +133,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",
@@ -158,15 +161,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",
@@ -191,6 +197,7 @@
"@typescript-eslint/parser": "^5.48.2",
"babel-jest": "^29.4.2",
"babel-loader": "^9.1.2",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-module-resolver": "^5.0.0",
"babel-plugin-react-native-web": "^0.18.12",
"detox": "^20.13.0",
@@ -237,8 +244,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__",
+14
View File
@@ -0,0 +1,14 @@
diff --git a/node_modules/babel-preset-expo/index.js b/node_modules/babel-preset-expo/index.js
index 2099ee3..2b9e092 100644
--- a/node_modules/babel-preset-expo/index.js
+++ b/node_modules/babel-preset-expo/index.js
@@ -105,7 +105,8 @@ module.exports = function (api, options = {}) {
],
],
plugins: [
- getObjectRestSpreadPlugin(),
+ // - dan: This will be disabled anyway when we upgrade Expo, but let's do it now.
+ // getObjectRestSpreadPlugin(),
...extraPlugins,
getAliasPlugin(),
[require.resolve('@babel/plugin-proposal-decorators'), { legacy: true }],
+12
View File
@@ -0,0 +1,12 @@
diff --git a/node_modules/babel-preset-fbjs/plugins/inline-requires.js b/node_modules/babel-preset-fbjs/plugins/inline-requires.js
index b11fc83..e18661a 100644
--- a/node_modules/babel-preset-fbjs/plugins/inline-requires.js
+++ b/node_modules/babel-preset-fbjs/plugins/inline-requires.js
@@ -256,6 +256,7 @@ function getInlineableModule(path, state) {
return moduleName == null ||
state.ignoredRequires.has(moduleName) ||
+ moduleName.startsWith('@babel/runtime/') ||
isRequireInScope
? null
: { moduleName, requireFnName: fnName };
+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),
+50
View File
@@ -0,0 +1,50 @@
diff --git a/node_modules/metro-runtime/src/polyfills/require.js b/node_modules/metro-runtime/src/polyfills/require.js
index ce67cb4..eeeae84 100644
--- a/node_modules/metro-runtime/src/polyfills/require.js
+++ b/node_modules/metro-runtime/src/polyfills/require.js
@@ -22,6 +22,13 @@ global.__c = clear;
global.__registerSegment = registerSegment;
var modules = clear();
+if (__DEV__) {
+ // Added by Dan for module init logging.
+ global.__INIT_LOGS__ = []
+ var initModuleCounter = 0
+ var initModuleStack = []
+}
+
// Don't use a Symbol here, it would pull in an extra polyfill with all sorts of
// additional stuff (e.g. Array.from).
const EMPTY = {};
@@ -303,7 +310,30 @@ function loadModuleImplementation(moduleId, module) {
throw module.error;
}
if (__DEV__) {
- var Systrace = requireSystrace();
+ // Added by Dan for module init logging.
+ var Systrace = {
+ beginEvent(label) {
+ let fullLabel = initModuleCounter++ + ' ' + label
+ global.__INIT_LOGS__.push(
+ ' '.repeat(initModuleStack.length) +
+ ' ENTER ' + fullLabel
+ )
+ initModuleStack.push({
+ fullLabel,
+ startTime: nativePerformanceNow(),
+ })
+ },
+ endEvent() {
+ const res = initModuleStack.pop()
+ const fullLabel = res.fullLabel
+ const startTime = res.startTime
+ const timeElapsed = Math.round(nativePerformanceNow() - startTime)
+ global.__INIT_LOGS__.push(
+ ' '.repeat(initModuleStack.length) +
+ ' LEAVE ' + fullLabel + ' [' + timeElapsed + 'ms]',
+ )
+ }
+ };
var Refresh = requireRefresh();
}
@@ -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);
+99 -43
View File
@@ -1,74 +1,130 @@
import 'react-native-url-polyfill/auto'
import 'lib/sentry' // must be near top
import React, {useState, useEffect} from 'react'
import 'lib/sentry' // must be relatively on top
import {withSentry} from 'lib/sentry'
import {Linking} from 'react-native'
import {RootSiblingParent} from 'react-native-root-siblings'
import * as SplashScreen from 'expo-splash-screen'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {observer} from 'mobx-react-lite'
import {QueryClientProvider} from '@tanstack/react-query'
import {enableFreeze} from 'react-native-screens'
import 'view/icons'
import {init as initPersistedState} from '#/state/persisted'
import {init as initReminders} from '#/state/shell/reminders'
import {listenSessionDropped} from './state/events'
import {useColorMode} from 'state/shell'
import {ThemeProvider} from 'lib/ThemeContext'
import {s} from 'lib/styles'
import * as view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from './view/shell'
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 {handleLink} from './Navigation'
import {QueryClientProvider} from '@tanstack/react-query'
import * as Toast from 'view/com/util/Toast'
import {queryClient} from 'lib/react-query'
import {TestCtrls} from 'view/com/testing/TestCtrls'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {
Provider as SessionProvider,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
import {messages} from './locale/locales/en/messages'
i18n.load('en', messages)
i18n.activate('en')
enableFreeze(true)
SplashScreen.preventAutoHideAsync()
const App = observer(function AppImpl() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
function InnerApp() {
const colorMode = useColorMode()
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
// init
useEffect(() => {
view.setup()
setupState().then(store => {
setRootStore(store)
analytics.init(store)
notifications.init(store)
SplashScreen.hideAsync()
Linking.getInitialURL().then((url: string | null) => {
if (url) {
handleLink(url)
}
})
Linking.addEventListener('url', ({url}) => {
handleLink(url)
})
store.onSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
initReminders()
analytics.init()
notifications.init(queryClient)
listenSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
}, [])
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
// show nothing prior to init
if (!rootStore) {
if (isInitialLoad) {
// TODO add a loading state
return null
}
/*
* Session and initial state should be loaded prior to rendering below.
*/
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</RootSiblingParent>
</I18nProvider>
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
)
}
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])
if (!isReady) {
return null
}
/*
* NOTE: only nothing here can depend on other data or session state, since
* that is set up in the InnerApp component above.
*/
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
)
})
}
export default withSentry(App)
export default App
+97 -31
View File
@@ -1,52 +1,118 @@
import 'lib/sentry' // must be near top
import React, {useState, useEffect} from 'react'
import 'lib/sentry' // must be relatively on top
import {QueryClientProvider} from '@tanstack/react-query'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {RootSiblingParent} from 'react-native-root-siblings'
import * as view from './view/index'
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 {observer} from 'mobx-react-lite'
import {QueryClientProvider} from '@tanstack/react-query'
import {queryClient} from 'lib/react-query'
import {enableFreeze} from 'react-native-screens'
const App = observer(function AppImpl() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
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 {Shell} from 'view/shell/index'
import {ToastContainer} from 'view/com/util/Toast.web'
import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query'
import {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
import {defaultLocale, dynamicActivate} from './locale/i18n'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {
Provider as SessionProvider,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
enableFreeze(true)
function InnerApp() {
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
// init
useEffect(() => {
view.setup()
setupState().then(store => {
setRootStore(store)
analytics.init(store)
})
}, [])
initReminders()
analytics.init()
dynamicActivate(defaultLocale) // async import of locale data
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
// show nothing prior to init
if (!rootStore) {
if (isInitialLoad) {
// TODO add a loading state
return null
}
/*
* Session and initial state should be loaded prior to rendering below.
*/
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<ToastContainer />
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</RootSiblingParent>
</I18nProvider>
<ToastContainer />
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
)
}
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])
if (!isReady) {
return null
}
/*
* NOTE: only nothing here can depend on other data or session state, since
* that is set up in the InnerApp component above.
*/
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
)
})
}
export default App
+87 -67
View File
@@ -1,6 +1,6 @@
import * as React from 'react'
import {StyleSheet} from 'react-native'
import {observer} from 'mobx-react-lite'
import * as SplashScreen from 'expo-splash-screen'
import {
NavigationContainer,
createNavigationContainerRef,
@@ -32,26 +32,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'
@@ -91,42 +91,47 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<>
<Stack.Screen
name="NotFound"
component={NotFoundScreen}
getComponent={() => NotFoundScreen}
options={{title: title('Not Found')}}
/>
<Stack.Screen
name="Lists"
component={ListsScreen}
options={{title: title('Lists')}}
/>
<Stack.Screen
name="Moderation"
component={ModerationScreen}
getComponent={() => ModerationScreen}
options={{title: title('Moderation')}}
/>
<Stack.Screen
name="ModerationMuteLists"
component={ModerationMuteListsScreen}
options={{title: title('Mute Lists')}}
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
options={{title: title('Moderation Lists')}}
/>
<Stack.Screen
name="ModerationMutedAccounts"
component={ModerationMutedAccounts}
getComponent={() => ModerationMutedAccounts}
options={{title: title('Muted Accounts')}}
/>
<Stack.Screen
name="ModerationBlockedAccounts"
component={ModerationBlockedAccounts}
getComponent={() => ModerationBlockedAccounts}
options={{title: title('Blocked Accounts')}}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
getComponent={() => SettingsScreen}
options={{title: title('Settings')}}
/>
<Stack.Screen
name="LanguageSettings"
component={LanguageSettingsScreen}
getComponent={() => LanguageSettingsScreen}
options={{title: title('Language Settings')}}
/>
<Stack.Screen
name="Profile"
component={ProfileScreen}
getComponent={() => ProfileScreen}
options={({route}) => ({
title: title(`@${route.params.name}`),
animation: 'none',
@@ -134,101 +139,101 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
/>
<Stack.Screen
name="ProfileFollowers"
component={ProfileFollowersScreen}
getComponent={() => ProfileFollowersScreen}
options={({route}) => ({
title: title(`People following @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileFollows"
component={ProfileFollowsScreen}
getComponent={() => ProfileFollowsScreen}
options={({route}) => ({
title: title(`People followed by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileList"
component={ProfileListScreen}
options={{title: title('Mute List')}}
getComponent={() => ProfileListScreen}
options={{title: title('List')}}
/>
<Stack.Screen
name="PostThread"
component={PostThreadScreen}
getComponent={() => PostThreadScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/>
<Stack.Screen
name="PostLikedBy"
component={PostLikedByScreen}
getComponent={() => PostLikedByScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/>
<Stack.Screen
name="PostRepostedBy"
component={PostRepostedByScreen}
getComponent={() => PostRepostedByScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/>
<Stack.Screen
name="CustomFeed"
component={CustomFeedScreen}
name="ProfileFeed"
getComponent={() => ProfileFeedScreen}
options={{title: title('Feed')}}
/>
<Stack.Screen
name="CustomFeedLikedBy"
component={CustomFeedLikedByScreen}
name="ProfileFeedLikedBy"
getComponent={() => ProfileFeedLikedByScreen}
options={{title: title('Liked by')}}
/>
<Stack.Screen
name="Debug"
component={DebugScreen}
getComponent={() => DebugScreen}
options={{title: title('Debug')}}
/>
<Stack.Screen
name="Log"
component={LogScreen}
getComponent={() => LogScreen}
options={{title: title('Log')}}
/>
<Stack.Screen
name="Support"
component={SupportScreen}
getComponent={() => SupportScreen}
options={{title: title('Support')}}
/>
<Stack.Screen
name="PrivacyPolicy"
component={PrivacyPolicyScreen}
getComponent={() => PrivacyPolicyScreen}
options={{title: title('Privacy Policy')}}
/>
<Stack.Screen
name="TermsOfService"
component={TermsOfServiceScreen}
getComponent={() => TermsOfServiceScreen}
options={{title: title('Terms of Service')}}
/>
<Stack.Screen
name="CommunityGuidelines"
component={CommunityGuidelinesScreen}
getComponent={() => CommunityGuidelinesScreen}
options={{title: title('Community Guidelines')}}
/>
<Stack.Screen
name="CopyrightPolicy"
component={CopyrightPolicyScreen}
getComponent={() => CopyrightPolicyScreen}
options={{title: title('Copyright Policy')}}
/>
<Stack.Screen
name="AppPasswords"
component={AppPasswords}
getComponent={() => AppPasswords}
options={{title: title('App Passwords')}}
/>
<Stack.Screen
name="SavedFeeds"
component={SavedFeeds}
getComponent={() => SavedFeeds}
options={{title: title('Edit My Feeds')}}
/>
<Stack.Screen
name="PreferencesHomeFeed"
component={PreferencesHomeFeed}
getComponent={() => PreferencesHomeFeed}
options={{title: title('Home Feed Preferences')}}
/>
<Stack.Screen
name="PreferencesThreads"
component={PreferencesThreads}
getComponent={() => PreferencesThreads}
options={{title: title('Threads Preferences')}}
/>
</>
@@ -253,14 +258,17 @@ function TabsNavigator() {
backBehavior="initialRoute"
screenOptions={{headerShown: false, lazy: true}}
tabBar={tabBar}>
<Tab.Screen name="HomeTab" component={HomeTabNavigator} />
<Tab.Screen name="SearchTab" component={SearchTabNavigator} />
<Tab.Screen name="FeedsTab" component={FeedsTabNavigator} />
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
<Tab.Screen name="FeedsTab" getComponent={() => FeedsTabNavigator} />
<Tab.Screen
name="NotificationsTab"
component={NotificationsTabNavigator}
getComponent={() => NotificationsTabNavigator}
/>
<Tab.Screen
name="MyProfileTab"
getComponent={() => MyProfileTabNavigator}
/>
<Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} />
</Tab.Navigator>
)
}
@@ -277,7 +285,7 @@ function HomeTabNavigator() {
animationDuration: 250,
contentStyle,
}}>
<HomeTab.Screen name="Home" component={HomeScreen} />
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
{commonScreens(HomeTab)}
</HomeTab.Navigator>
)
@@ -294,7 +302,7 @@ function SearchTabNavigator() {
animationDuration: 250,
contentStyle,
}}>
<SearchTab.Screen name="Search" component={SearchScreen} />
<SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
{commonScreens(SearchTab as typeof HomeTab)}
</SearchTab.Navigator>
)
@@ -311,7 +319,7 @@ function FeedsTabNavigator() {
animationDuration: 250,
contentStyle,
}}>
<FeedsTab.Screen name="Feeds" component={FeedsScreen} />
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
{commonScreens(FeedsTab as typeof HomeTab)}
</FeedsTab.Navigator>
)
@@ -330,16 +338,15 @@ function NotificationsTabNavigator() {
}}>
<NotificationsTab.Screen
name="Notifications"
component={NotificationsScreen}
getComponent={() => NotificationsScreen}
/>
{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={{
@@ -352,26 +359,25 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
<MyProfileTab.Screen
name="MyProfile"
// @ts-ignore // TODO: fix this broken type in ProfileScreen
component={ProfileScreen}
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={{
@@ -383,28 +389,28 @@ const FlatNavigator = observer(function FlatNavigatorImpl() {
}}>
<Flat.Screen
name="Home"
component={HomeScreen}
getComponent={() => HomeScreen}
options={{title: title('Home')}}
/>
<Flat.Screen
name="Search"
component={SearchScreen}
getComponent={() => SearchScreen}
options={{title: title('Search')}}
/>
<Flat.Screen
name="Feeds"
component={FeedsScreen}
getComponent={() => FeedsScreen}
options={{title: title('Feeds')}}
/>
<Flat.Screen
name="Notifications"
component={NotificationsScreen}
getComponent={() => NotificationsScreen}
options={{title: title('Notifications')}}
/>
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
)
})
}
/**
* The RoutesContainer should wrap all components which need access
@@ -462,11 +468,13 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING}
theme={theme}
onReady={() => {
// Register the navigation container with the Sentry instrumentation (only works on native)
if (isNative) {
const routingInstrumentation = getRoutingInstrumentation()
routingInstrumentation.registerNavigationContainer(navigationRef)
}
SplashScreen.hideAsync()
const initMs = Math.round(
// @ts-ignore Emitted by Metro in the bundle prelude
performance.now() - global.__BUNDLE_START_TIME__,
)
console.log(`Time to first paint: ${initMs} ms`)
logModuleInitTrace()
}}>
{children}
</NavigationContainer>
@@ -575,6 +583,18 @@ const styles = StyleSheet.create({
},
})
function logModuleInitTrace() {
if (__DEV__) {
// This log is noisy, so keep false committed
const shouldLog = false
// Relies on our patch to polyfill.js in metro-runtime
const initLogs = (global as any).__INIT_LOGS__
if (shouldLog && Array.isArray(initLogs)) {
console.log(initLogs.join('\n'))
}
}
}
export {
navigate,
resetToTab,
+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.email) {
store.log.debug('Ping w/hash')
const email_hashed = sha256(sess.email)
segmentClient.identify(email_hashed, {email_hashed})
} 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
}
+15 -14
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.email) {
store.log.debug('Ping w/hash')
const email_hashed = sha256(sess.email)
segmentClient.identify(email_hashed, {email_hashed})
export function init() {
listenSessionLoaded(account => {
if (account.did) {
if (account.did) {
const did_hashed = sha256(account.did)
segmentClient.identify(did_hashed, {did_hashed})
logger.debug('Ping w/hash')
} else {
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': {}
+14 -3
View File
@@ -4,7 +4,7 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
} from '@atproto/api'
import {FeedSourceInfo} from './feed/types'
import {ReasonFeedSource} from './feed/types'
import {isPostInLanguage} from '../../locale/helpers'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
@@ -65,9 +65,9 @@ export class FeedViewPostsSlice {
)
}
get source(): FeedSourceInfo | undefined {
get source(): ReasonFeedSource | undefined {
return this.items.find(item => '__source' in item && !!item.__source)
?.__source as FeedSourceInfo
?.__source as ReasonFeedSource
}
containsUri(uri: string) {
@@ -116,6 +116,17 @@ export class FeedViewPostsSlice {
}
}
export class NoopFeedTuner {
reset() {}
tune(
feed: FeedViewPost[],
_tunerFns: FeedTunerFn[] = [],
_opts?: {dryRun: boolean; maintainOrder: boolean},
): FeedViewPostsSlice[] {
return feed.map(item => new FeedViewPostsSlice([item]))
}
}
export class FeedTuner {
seenUris: Set<string> = new Set()
+59 -14
View File
@@ -1,45 +1,90 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
BskyAgent,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class AuthorFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public rootStore: RootStoreModel,
public agent: BskyAgent,
public params: GetAuthorFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getAuthorFeed({
const res = await this.agent.getAuthorFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getAuthorFeed({
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getAuthorFeed({
...this.params,
cursor: this.cursor,
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
feed: this._filter(res.data.feed),
}
}
return {
feed: [],
}
}
_filter(feed: AppBskyFeedDefs.FeedViewPost[]) {
if (this.params.filter === 'posts_no_replies') {
return feed.filter(post => {
const isReply = post.reply
const isRepost = AppBskyFeedDefs.isReasonRepost(post.reason)
if (!isReply) return true
if (isRepost) return true
return isReply && isAuthorReplyChain(this.params.actor, post, feed)
})
}
return feed
}
}
function isAuthorReplyChain(
actor: string,
post: AppBskyFeedDefs.FeedViewPost,
posts: AppBskyFeedDefs.FeedViewPost[],
): boolean {
// current post is by a different user (shouldn't happen)
if (post.post.author.handle !== actor) return false
const replyParent = post.reply?.parent
if (AppBskyFeedDefs.isPostView(replyParent)) {
// reply parent is by a different user
if (replyParent.author.handle !== actor) return false
// A top-level post that matches the parent of the current post.
const parentPost = posts.find(p => p.post.uri === replyParent.uri)
/*
* Either we haven't fetched the parent at the top level, or the only
* record we have is on feedItem.reply.parent, which we've already checked
* above.
*/
if (!parentPost) return true
// Walk up to parent
return isAuthorReplyChain(actor, parentPost, posts)
}
// Just default to showing it
return true
}
+12 -13
View File
@@ -1,38 +1,37 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class CustomFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public rootStore: RootStoreModel,
public agent: BskyAgent,
public params: GetCustomFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
const res = await this.agent.app.bsky.feed.getFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getFeed({
...this.params,
cursor: this.cursor,
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
+12 -14
View File
@@ -1,30 +1,28 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
export class FollowingFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(public rootStore: RootStoreModel) {}
reset() {
this.cursor = undefined
}
constructor(public agent: BskyAgent) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getTimeline({
const res = await this.agent.getTimeline({
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getTimeline({
cursor: this.cursor,
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getTimeline({
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
+12 -13
View File
@@ -1,38 +1,37 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetActorLikes as GetActorLikes,
BskyAgent,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class LikesFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public rootStore: RootStoreModel,
public agent: BskyAgent,
public params: GetActorLikes.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getActorLikes({
const res = await this.agent.getActorLikes({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getActorLikes({
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getActorLikes({
...this.params,
cursor: this.cursor,
cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
+44
View File
@@ -0,0 +1,44 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetListFeed as GetListFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
export class ListFeedAPI implements FeedAPI {
constructor(
public agent: BskyAgent,
public params: GetListFeed.QueryParams,
) {}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.app.bsky.feed.getListFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getListFeed({
...this.params,
cursor,
limit,
})
if (res.success) {
return {
cursor: res.data.cursor,
feed: res.data.feed,
}
}
return {
feed: [],
}
}
}
+82 -64
View File
@@ -1,11 +1,12 @@
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
import shuffle from 'lodash.shuffle'
import {RootStoreModel} from 'state/index'
import {timeout} from 'lib/async/timeout'
import {bundleAsync} from 'lib/async/bundle'
import {feedUriToHref} from 'lib/strings/url-helpers'
import {FeedTuner} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -17,28 +18,49 @@ export class MergeFeedAPI implements FeedAPI {
itemCursor = 0
sampleCursor = 0
constructor(public rootStore: RootStoreModel) {
this.following = new MergeFeedSource_Following(this.rootStore)
constructor(
public agent: BskyAgent,
public params: FeedParams,
public feedTuners: FeedTunerFn[],
) {
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
}
reset() {
this.following = new MergeFeedSource_Following(this.rootStore)
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
this.feedCursor = 0
this.itemCursor = 0
this.sampleCursor = 0
if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
this.customFeeds = shuffle(
this.params.mergeFeedSources.map(
feedUri =>
new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners),
),
)
} else {
this.customFeeds = []
}
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getTimeline({
const res = await this.agent.getTimeline({
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
// we capture here to ensure the data has loaded
this._captureFeedsIfNeeded()
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
if (!cursor) {
this.reset()
}
const promises = []
@@ -76,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI {
}
return {
cursor: posts.length ? 'fake' : undefined,
cursor: posts.length ? String(this.itemCursor) : undefined,
feed: posts,
}
}
@@ -107,33 +129,15 @@ export class MergeFeedAPI implements FeedAPI {
// provide follow
return this.following.take(1)
}
_captureFeedsIfNeeded() {
if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) {
return
}
if (this.customFeeds.length === 0) {
this.customFeeds = shuffle(
this.rootStore.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 agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
get numReady() {
return this.queue.length
@@ -195,16 +199,12 @@ class MergeFeedSource_Following extends MergeFeedSource {
cursor: string | undefined,
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.rootStore.agent.getTimeline({cursor, limit})
const res = await this.agent.getTimeline({cursor, limit})
// run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune(
res.data.feed,
this.rootStore.preferences.getFeedTuners('home'),
{
dryRun: false,
maintainOrder: true,
},
)
const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
dryRun: false,
maintainOrder: true,
})
res.data.feed = slices.map(slice => slice.rootItem)
return res
}
@@ -214,42 +214,60 @@ class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date
constructor(
public rootStore: RootStoreModel,
public agent: BskyAgent,
public feedUri: string,
public feedDisplayName: string,
public feedTuners: FeedTunerFn[],
) {
super(rootStore)
super(agent, feedTuners)
this.sourceInfo = {
displayName: feedDisplayName,
$type: 'reasonFeedSource',
displayName: feedUri.split('/').pop() || '',
uri: feedUriToHref(feedUri),
}
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
this.agent.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 this.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)
}
// 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
}
+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
+12 -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 = [
@@ -147,3 +156,4 @@ export const HITSLOP_10 = createHitslop(10)
export const HITSLOP_20 = createHitslop(20)
export const HITSLOP_30 = createHitslop(30)
export const BACK_HITSLOP = HITSLOP_30
export const MAX_POST_LINES = 25
+18 -30
View File
@@ -1,41 +1,29 @@
import {useCallback, useState} from 'react'
import {useStores} from 'state/index'
import {useAnalytics} from 'lib/analytics/analytics'
import {StackActions, useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {AccountData} from 'state/models/session'
import {reset as resetNavigation} from '../../Navigation'
import * as Toast from 'view/com/util/Toast'
import {useCallback} from 'react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {useSessionApi, SessionAccount} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {useCloseAllActiveElements} from '#/state/util'
export function useAccountSwitcher(): [
boolean,
(v: boolean) => void,
(acct: AccountData) => Promise<void>,
] {
export function useAccountSwitcher() {
const {track} = useAnalytics()
const store = useStores()
const [isSwitching, setIsSwitching] = useState(false)
const navigation = useNavigation<NavigationProp>()
const {selectAccount, clearCurrentAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const onPressSwitchAccount = useCallback(
async (acct: AccountData) => {
async (acct: SessionAccount) => {
track('Settings:SwitchAccountButtonClicked')
setIsSwitching(true)
const success = await store.session.resumeSession(acct)
store.shell.closeAllActiveElements()
if (success) {
resetNavigation()
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
} else {
try {
await selectAccount(acct)
closeAllActiveElements()
Toast.show(`Signed in as ${acct.handle}`)
} catch (e) {
Toast.show('Sorry! We need you to enter your password.')
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
clearCurrentAccount() // back user out to login
}
},
[track, setIsSwitching, navigation, store],
[track, clearCurrentAccount, selectAccount, closeAllActiveElements],
)
return [isSwitching, setIsSwitching, onPressSwitchAccount]
return {onPressSwitchAccount}
}
@@ -0,0 +1,15 @@
// Be warned. This Hook is very buggy unless used in a very constrained way.
// To use it safely:
//
// - DO NOT pass its return value as a prop to any user-defined component.
// - DO NOT pass its return value to more than a single component.
//
// In other words, the only safe way to use it is next to the leaf Reanimated View.
//
// Relevant bug reports:
// - https://github.com/software-mansion/react-native-reanimated/issues/5345
// - https://github.com/software-mansion/react-native-reanimated/issues/5360
// - https://github.com/software-mansion/react-native-reanimated/issues/5364
//
// It's great when it works though.
export {useAnimatedScrollHandler} from 'react-native-reanimated'
@@ -0,0 +1,44 @@
import {useRef, useEffect} from 'react'
import {useAnimatedScrollHandler as useAnimatedScrollHandler_BUGGY} from 'react-native-reanimated'
export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = (
config,
deps,
) => {
const ref = useRef(config)
useEffect(() => {
ref.current = config
})
return useAnimatedScrollHandler_BUGGY(
{
onBeginDrag(e) {
if (typeof ref.current !== 'function' && ref.current.onBeginDrag) {
ref.current.onBeginDrag(e)
}
},
onEndDrag(e) {
if (typeof ref.current !== 'function' && ref.current.onEndDrag) {
ref.current.onEndDrag(e)
}
},
onMomentumBegin(e) {
if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) {
ref.current.onMomentumBegin(e)
}
},
onMomentumEnd(e) {
if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) {
ref.current.onMomentumEnd(e)
}
},
onScroll(e) {
if (typeof ref.current === 'function') {
ref.current(e)
} else if (ref.current.onScroll) {
ref.current.onScroll(e)
}
},
},
deps,
)
}
-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
}
+15 -14
View File
@@ -1,14 +1,15 @@
import * as Updates from 'expo-updates'
import {useCallback, useEffect} from 'react'
import {AppState} from 'react-native'
import {useStores} from 'state/index'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
export function useOTAUpdate() {
const store = useStores()
const {openModal} = useModalControls()
// HELPER FUNCTIONS
const showUpdatePopup = useCallback(() => {
store.shell.openModal({
openModal({
name: 'confirm',
title: 'Update Available',
message:
@@ -19,9 +20,9 @@ export function useOTAUpdate() {
})
},
})
}, [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.invalidateQueries({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.invalidateQueries({queryKey: RQKEY_NOTIFS()})
resetToTab('NotificationsTab') // open notifications tab
}
},
+11 -1
View File
@@ -1,3 +1,13 @@
import {QueryClient} from '@tanstack/react-query'
export const queryClient = new QueryClient()
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// NOTE
// refetchOnWindowFocus breaks some UIs (like feeds)
// so we NEVER want to enable this
// -prf
refetchOnWindowFocus: false,
},
},
})
-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
+2 -46
View File
@@ -1,52 +1,8 @@
import {isNative, isWeb} from 'platform/detection'
import {FC} from 'react'
import * as Sentry from 'sentry-expo'
import {init} from 'sentry-expo'
// Sentry Initialization
export const getRoutingInstrumentation = () => {
return new Sentry.Native.ReactNavigationInstrumentation() // initialize this in `onReady` prop of NavigationContainer
}
Sentry.init({
init({
dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode.
debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
environment: __DEV__ ? 'development' : 'production', // Set the environment
// @ts-ignore exists but not in types, see https://docs.sentry.io/platforms/react-native/configuration/options/#enableAutoPerformanceTracking
enableAutoPerformanceTracking: true, // Enable auto performance tracking
tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production
_experiments: {
// The sampling rate for profiling is relative to TracesSampleRate.
// In this case, we'll capture profiles for 50% of transactions.
profilesSampleRate: 0.5,
},
integrations: isNative
? [
new Sentry.Native.ReactNativeTracing({
shouldCreateSpanForRequest: url => {
// Do not create spans for outgoing requests to a `/logs` endpoint as it is too noisy due to expo
return !url.match(/\/logs$/)
},
routingInstrumentation: getRoutingInstrumentation(),
}),
]
: [], // no integrations for web, yet
})
// if web, use Browser client, otherwise use Native client
export function getSentryClient() {
if (isWeb) {
return Sentry.Browser
}
return Sentry.Native
}
// wrap root App component with Sentry for automatic touch event tracking and performance monitoring
export function withSentry(Component: FC) {
if (isWeb) {
return Component // .wrap is not required or available for web
}
const sentryClient = getSentryClient()
return sentryClient.wrap(Component)
}
+1 -1
View File
@@ -1,4 +1,4 @@
export function bskyTitle(page: string, unreadCountLabel?: string) {
const unreadPrefix = unreadCountLabel ? `(${unreadCountLabel}) ` : ''
return `${unreadPrefix}${page} - Bluesky`
return `${unreadPrefix}${page} Bluesky`
}
+5
View File
@@ -32,3 +32,8 @@ export function toHashCode(str: string, seed = 0): number {
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
}
export function countLines(str: string | undefined): number {
if (!str) return 0
return str.match(/\n/g)?.length ?? 0
}
+1 -1
View File
@@ -1,5 +1,5 @@
import {AtUri} from '@atproto/api'
import {PROD_SERVICE} from 'state/index'
import {PROD_SERVICE} from 'lib/constants'
import TLDs from 'tlds'
import psl from 'psl'
+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'},
+20
View File
@@ -0,0 +1,20 @@
import {i18n} from '@lingui/core'
export const locales = {
en: 'English',
cs: 'Česky',
fr: 'Français',
hi: 'हिंदी',
es: 'Español',
}
export const defaultLocale = 'en'
/**
* We do a dynamic import of just the catalog that we need
* @param locale any locale string
*/
export async function dynamicActivate(locale: string) {
const {messages} = await import(`./locales/${locale}/messages`)
i18n.load(locale, messages)
i18n.activate(locale)
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+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())
})
+424
View File
@@ -0,0 +1,424 @@
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('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
+290
View File
@@ -0,0 +1,290 @@
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],
}
/**
* Used in dev mode to nicely log to the console
*/
export const consoleTransport: Transport = (
level,
message,
metadata,
timestamp,
) => {
const extra = Object.keys(metadata).length
? ' ' + 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,
) => {
/**
* 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: metadata,
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: metadata,
})
}
} else {
/**
* It's otherwise an Error and should be reported with captureException
*/
Sentry.captureException(message, {
tags,
extra: metadata,
})
}
}
/**
* 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()
/**
* Report to console in dev, Sentry in prod, nothing in test.
*/
if (env.IS_DEV && !env.IS_TEST) {
logger.addTransport(consoleTransport)
/**
* Uncomment this to test Sentry in dev
*/
// 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'
-16
View File
@@ -1,5 +1,4 @@
import 'fast-text-encoding'
import Graphemer from 'graphemer'
// @ts-ignore no decl -prf
import findLast from 'array.prototype.findlast'
export {}
@@ -54,18 +53,3 @@ globalThis.atob = (str: string): string => {
}
return result
}
const splitter = new Graphemer()
globalThis.Intl = globalThis.Intl || {}
// @ts-ignore we're polyfilling -prf
globalThis.Intl.Segmenter =
// @ts-ignore we're polyfilling -prf
globalThis.Intl.Segmenter ||
class Segmenter {
constructor() {}
// NOTE
// this is not a precisely correct polyfill but it's sufficient for our needs
// -prf
segment = splitter.iterateGraphemes
}
-13
View File
@@ -6,16 +6,3 @@ findLast.shim()
// @ts-ignore whatever typescript wants to complain about here, I dont care about -prf
window.setImmediate = (cb: () => void) => setTimeout(cb, 0)
// @ts-ignore not on the TS signature due to bad support -prf
if (!globalThis.Intl?.Segmenter) {
// NOTE loading as a separate script to reduce main bundle size, as this is only needed in FF -prf
const script = document.createElement('script')
script.setAttribute('src', '/static/js/intl-segmenter-polyfill.min.js')
document.head.appendChild(script)
// loading emoji mart data
const emojiMartScript = document.createElement('script')
emojiMartScript.setAttribute('src', '/static/js/emoji-mart-data.js')
document.head.appendChild(emojiMartScript)
}
+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',
+101
View File
@@ -0,0 +1,101 @@
import {useEffect, useState, useCallback, useRef} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyFeedDefs} from '@atproto/api'
import {Shadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
export interface PostShadow {
likeUri: string | undefined
likeCount: number | undefined
repostUri: string | undefined
repostCount: number | undefined
isDeleted: boolean
}
export const POST_TOMBSTONE = Symbol('PostTombstone')
interface CacheEntry {
ts: number
value: PostShadow
}
export function usePostShadow(
post: AppBskyFeedDefs.PostView,
ifAfterTS: number,
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
const [state, setState] = useState<CacheEntry>({
ts: Date.now(),
value: fromPost(post),
})
const firstRun = useRef(true)
const onUpdate = useCallback(
(value: Partial<PostShadow>) => {
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
},
[setState],
)
// react to shadow updates
useEffect(() => {
emitter.addListener(post.uri, onUpdate)
return () => {
emitter.removeListener(post.uri, onUpdate)
}
}, [post.uri, onUpdate])
// react to post updates
useEffect(() => {
// dont fire on first run to avoid needless re-renders
if (!firstRun.current) {
setState({ts: Date.now(), value: fromPost(post)})
}
firstRun.current = false
}, [post])
return state.ts > ifAfterTS
? mergeShadow(post, state.value)
: {...post, isShadowed: true}
}
export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
emitter.emit(uri, value)
}
export function isPostShadowed(
v: AppBskyFeedDefs.PostView | Shadow<AppBskyFeedDefs.PostView>,
): v is Shadow<AppBskyFeedDefs.PostView> {
return 'isShadowed' in v && !!v.isShadowed
}
function fromPost(post: AppBskyFeedDefs.PostView): PostShadow {
return {
likeUri: post.viewer?.like,
likeCount: post.likeCount,
repostUri: post.viewer?.repost,
repostCount: post.repostCount,
isDeleted: false,
}
}
function mergeShadow(
post: AppBskyFeedDefs.PostView,
shadow: PostShadow,
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
if (shadow.isDeleted) {
return POST_TOMBSTONE
}
return {
...post,
likeCount: shadow.likeCount,
repostCount: shadow.repostCount,
viewer: {
...(post.viewer || {}),
like: shadow.likeUri,
repost: shadow.repostUri,
},
isShadowed: true,
}
}
+99
View File
@@ -0,0 +1,99 @@
import {useEffect, useState, useCallback, useRef} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyActorDefs} from '@atproto/api'
import {Shadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
export interface ProfileShadow {
followingUri: string | undefined
muted: boolean | undefined
blockingUri: string | undefined
}
interface CacheEntry {
ts: number
value: ProfileShadow
}
type ProfileView =
| AppBskyActorDefs.ProfileView
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed
export function useProfileShadow(
profile: ProfileView,
ifAfterTS: number,
): Shadow<ProfileView> {
const [state, setState] = useState<CacheEntry>({
ts: Date.now(),
value: fromProfile(profile),
})
const firstRun = useRef(true)
const onUpdate = useCallback(
(value: Partial<ProfileShadow>) => {
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
},
[setState],
)
// react to shadow updates
useEffect(() => {
emitter.addListener(profile.did, onUpdate)
return () => {
emitter.removeListener(profile.did, onUpdate)
}
}, [profile.did, onUpdate])
// react to profile updates
useEffect(() => {
// dont fire on first run to avoid needless re-renders
if (!firstRun.current) {
setState({ts: Date.now(), value: fromProfile(profile)})
}
firstRun.current = false
}, [profile])
return state.ts > ifAfterTS
? mergeShadow(profile, state.value)
: {...profile, isShadowed: true}
}
export function updateProfileShadow(
uri: string,
value: Partial<ProfileShadow>,
) {
emitter.emit(uri, value)
}
export function isProfileShadowed<T extends ProfileView>(
v: T | Shadow<T>,
): v is Shadow<T> {
return 'isShadowed' in v && !!v.isShadowed
}
function fromProfile(profile: ProfileView): ProfileShadow {
return {
followingUri: profile.viewer?.following,
muted: profile.viewer?.muted,
blockingUri: profile.viewer?.blocking,
}
}
function mergeShadow(
profile: ProfileView,
shadow: ProfileShadow,
): Shadow<ProfileView> {
return {
...profile,
viewer: {
...(profile.viewer || {}),
following: shadow.followingUri,
muted: shadow.muted,
blocking: shadow.blockingUri,
},
isShadowed: true,
}
}

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