From f28334739b107f3e9f7b6ca2670778dba280600d Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 22 Feb 2023 14:23:57 -0600 Subject: Merge main into the Web PR (#230) * Update to RN 71.1.0 (#100) * Update to RN 71 * Adds missing lint plugin * Add missing native changes * Bump @atproto/api@0.0.7 (#112) * Image not loading on swipe (#114) * Adds prefetching to images * Adds image prefetch * bugfix for images not showing on swipe * Fixes prefetch bug * Update src/view/com/util/PostEmbeds.tsx --------- Co-authored-by: Paul Frazee * Fixes to session management (#117) * Update session-management to solve incorrectly dropped sessions * Reset the nav on account switch * Reset the feed on me.load() * Update tests to reflect new account-switching behavior * Increase max image resolutions and sizes (#118) * Slightly increase the hitslop for post controls * Fix character counter color in dark mode * Update login to use new session.create api, which enables email login (close #93) (#119) * Replaces the alert with dropdown for profile image and banner (#123) * replaces the alert with dropdown for profile image and banner * lint * Fix to ordering of images in the embed grid (#121) * Add explicit link-embed controls to the composer (#120) * Add explicit link-embed controls * Update the target rez/size of link embed thumbs * Remove the alert before publishing without a link card * [Draft] Fixes image failing on reupload issue (#128) * Fixes image failing on reupload issue * Use tmp folder instead of documents * lint * Image performance improvements (#126) * Switch out most images for FastImage * Add image loading placeholders * Fix tests * Collection of fixes to list rendering (#127) * Fix bug that caused endless spinners in profile feeds * Bundle fetches of suggested actors into one update * Fixes to suggested follow rendering * Fix missing replacement of flex:1 to height:100 * Fixes to navigation swipes (#129) * Nav swipe: increase the distance traveled in response to gesture movement. This causes swipes to feel faster and more responsive. * Fix: fully clamp the swipe against the edge * Improve the performance of swipes by skipping the interaction manager * Adds dark mode to the edit screen (#130) * Adds dark mode to edit screen * lint * lint * lint * Reduce render cost of post controls and improve perceived responsiveness (#132) * Move post control animations into conditional render and increase perceived responsiveness * Remove log * Adds dark mode to the dropdown (#131) * Adds dark mode to the bottom sheet * Make background button lighter (like before) * lint * Fix bug in lightbox rendering (#133) * Fix layout in onboarding to not overflow the footer * Configure feed FlatList (removeClippedSubviews=true) to improve scroll performance (#136) * Disable like/repost animations to see if theyre causing #135 (#137) * Composer: mention tagging now works in middle of text (close #105) (#139) * Implement account deletion (#141) * Fix photo & camera permission management (#140) * Check photo & camera perms and alert the user if not available (close #64) - Adds perms checks with a prompt to update settings if needed - Moves initial access of photos in the composer so that the initial prompt occurs at an intuitive time. * Add react-native-permissions test mock * Fix issue causing multiple access requests * Use longer var names * Update podfile.lock * Lint fix * Move photo perm request in composer to the gallery btn instead of when the carousel is opened * Adds more tracking all around the app (#142) * Adds more tracking all around the app * more events * lint * using better analytics naming * missed file * more fixes * Calculate image aspect ratio on load (#146) * Calculate image aspect ratio on load * Move aspect ratio bounds to constants * Adds detox testing and instructions (#147) * Adds detox testing and instructions * lint * lint * Error cleanup (close #79) (#148) * Avoid surfacing errors to the user when it's not critical * Remove now-unused GetAssertionsView * Apply cleanError() consistently * Give a better error message for Upstream Failures (http status 502) * Hide errors in notifications because they're not useful * More e2e tests (create account) (#150) * Adds respots under the 'post' tab under profile (#158) * Adds dark mode to delete account screen (#159) * 87 dark mode edit profile (#162) * Adds dark mode to delete account screen * Adds one more missed darkmode * more fixes * Remove fallback gradient on external links without thumbs (#164) * Remove fallback gradient on external links without thumbs * Remove fallback gradient on external links without thumbs in the composer preview * Fix refresh behavior around a series of models (repost, graph, vote) (#163) * Fix refresh behavior around a series of models (repost, graph, vote) * Fix cursor behavior in reposted-by view * Fixes issue where retrying on image upload fails (#166) * Fixes issue where retrying on image upload fails * Lint, longer test time * Longer waitfor time in tests * even longer timeout * longer timeout * missed file * Update src/view/com/composer/ComposePost.tsx Co-authored-by: Paul Frazee * Update src/view/com/composer/ComposePost.tsx Co-authored-by: Paul Frazee --------- Co-authored-by: Paul Frazee * 154 cached image profile (#167) * Fixes issue where retrying on image upload fails * Lint, longer test time * Longer waitfor time in tests * even longer timeout * longer timeout * missed file * Fixes image cache error on second try for profile screen * lint * lint * lint * Refactor session management to use a new "Agent" API (#165) * Add the atp-agent implementation (temporarily in this repo) * Rewrite all session & API management to use the new atp-agent * Update tests for the atp-agent refactor * Refactor management of session-related state. Includes: - More careful management of when state is cleared or fetched - Debug logging to help trace future issues - Clearer APIs overall * Bubble session-expiration events to the user and display a toast to explain * Switch to the new @atproto/api@0.1.0 * Minor aesthetic cleanup in SessionModel * Wire up ReportAccount and ReportPost (#168) * Fixes embeds for youtube channels (#169) * Bump app ios version to 1.1 (needed after app store submission) * Fix potential issues with promise guards when an error occurs (#170) * Refactor models to use bundleAsync and lock regions (#171) * Fix to an edge case with feed re-ordering for threads (#172) * 151 fix youtube channel embed (#173) * Fixes embeds for youtube channels * Tests for youtube extract meta * lint * Add 'doesnt use non-exempt encryption' to ios config * Rework the search UI and add (#174) * Add search tab and move icon to footer * Remove subtitles from view header * Remove unused code * Clean up UI of search screen * Search: give better user feedback to UI state and add a cancel button * Add WhoToFollow section to search * Add a temporary SuggestedPosts solution using the patented 'bsky team algo' * Trigger reload of suggested content in search on open * Wait five min between reloading discovery content * Reduce weight of solid search icon in footer * Fix lint * Fix tests * 151 feat youtube embed iframe (#176) * youtube embed iframe temp commit * Fixes styling and code cleanup * lint * Now clicking between the pause and settings button doesn't trigger the parent * use modest branding (less yt logos) * Stop playing the video once there's a navigation event * Make sure the iframe is unmounted on any navigation event * fixes tests * lint * Add scroll-to-top for all screens (#177) * Adds hardcoded suggested list (#178) * Adds hardcoded suggested list * Update suggested-actors-view to support page sizes smaller than the hardcoded list --------- Co-authored-by: Paul Frazee * more robust centering of the play button (#181) Co-authored-by: Aryan Goharzad * Bundle of UI modifications (#175) * Adjust visual balance of SuggestedPosts and WhoToFollow * Fix bug in the discovery load trigger * Adjust search header aesthetic and have it scroll away * More visual balance tweaks on the search page * Even more visual balance tweaks on the search page * Hide the footer on scroll in search * Ditch the composer prompt buttons in the home feed * Center the view header title * Hide header on scroll on the home feed * Fix e2e tests * Fix home feed positioning (closes #189) (#195) * Fix home feed positioning for floating header * Fix positioning of errors in home feed * Fix lint * Don't show new-content notification for reposts (close #179) (#197) * Show the splash screen during session resumption (close #186) (#199) * Fix to suggested follows: chunk the hardcoded fetches to 25 at a time (close #196) (#198) * UI updates to the floating action button (#201) * Update FAB to use a plus icon and not drop shadow * Update FAB positioning to be more consistent in different shell modes * Animate the FAB's repositioning * Remove the 'loading' placeholder from images as it degraded feed perf (#202) * Remove the 'loading' placeholder from images as it degraded feed perf * Remove references * Fix RN bug that causes home feed not to load more; also fix home feed load view. (#208) RN has a bug where rendering a flatlist with an empty array appears to break its virtual list windowing behaviors. See https://stackoverflow.com/a/67873596 * Only give the loading spinner on the home feed during PTR (#207) (cherry picked from commit b7a5da12fdfacef74873b5cf6d75f20d259bde0e) * Implement our own lifecycle tracking to ensure it never fires while the app is backgrounded (close #193) (#211) * Push notification fixes (#210) * Fix to when screen analytics events are firing * Fix: dont trigger update state when backgrounded * Small fix to notifee API usage * Fix: properly load notification info for push card * Add feedback link to main menu (close #191) (#212) * Add "follows you" information and sync follow state between views (#215) * Bump @atproto/api@0.1.2 and update API usage * Add 'follows you' pill to profile header (close #110) * Add 'follows you' to followers and follows (close #103) * Update reposted-by and liked-by views to use the same components as followers and following * Create a local follows cache MyFollowsModel to keep views in sync (close #205) * Add incremental hydration to the MyFollows model * Fix tests * Update deps * Fix lint * Fix to paginated fetches * Fix reference * Fix potential state-desync issue * Fixes to notifications (#216) * Improve push-notification for follows * Refresh notifications on screen open (close #214) * Avoid showing loader more than needed in post threads * Refactor notification polling to handle view-state more effectively * Delete a bunch of tests taht werent adding value * Remove the accounts integration test; we'll use the e2e test instead * Load latest in notifications when the screen is open rather than full refresh * Randomize hard-coded suggested follows (#226) * Ensure follows are loaded before filtering hardcoded suggestions * Randomize hard-coded suggested profiles (close #219) * Sanitizes posts on publish and render (#217) * Sanatizes posts on publish and render * lint * lint and added sanitize to thread view as well * adjusts indices based on replaced text * Woops, fixes a bug * bugfix + cleanup * comment * lint * move sanitize text to later in the flow * undo changes to compose post * Add RichText library building upon the sanitizePost library method * Add lodash.clonedeep dep * Switch to RichText processing on record load & render * Fix lint --------- Co-authored-by: Paul Frazee * A group of notifications fixes (#227) * Fix: don't group together notifications that can't visually be grouped (close #221) * Mark all notifications read on PTR * Small optimization: useCallback and useMemo in posts feed * Add loading spinner to footer of notifications (close #222) * Fix to scrolling to posts within a thread (#228) * Fix: render the entire thread at start so that scrollToIndex works always (close #270) * Visual fixes to thread 'load more' * A few small perf improvements to thread rendering * Fix lint * 1.2 * Remove unused logger lib * Remove state-mock * Type fixes * Reorganize the folder structure for lib and switch to typescript path aliases * Move build-flags into lib * Move to the state path alias * Add view path alias * Fix lint * iOS build fixes * Wrap analytics in native/web splitter and re-enable in all view code * Add web version of react-native-webview * Add web split for version number * Fix BlurView import for web * Add web split for fastimage * Create web split for permissions lib * Fix for web high priority images --------- Co-authored-by: Aryan Goharzad --- src/App.native.tsx | 47 +- src/build-flags.ts | 2 - src/lib/ThemeContext.tsx | 95 ++ src/lib/analytics.tsx | 74 ++ src/lib/analytics.web.tsx | 16 + src/lib/api/api-polyfill.ts | 79 ++ src/lib/api/api-polyfill.web.ts | 4 + src/lib/api/index.ts | 201 +++ src/lib/app-info.ts | 4 + src/lib/app-info.web.ts | 3 + src/lib/assets.native.ts | 5 + src/lib/assets.ts | 10 + src/lib/async/bundle.ts | 24 + src/lib/bg-scheduler.ts | 18 + src/lib/bg-scheduler.web.ts | 13 + src/lib/build-flags.ts | 2 + src/lib/constants.ts | 65 + src/lib/errors.ts | 4 - src/lib/extractBskyMeta.ts | 99 -- src/lib/extractHtmlMeta.ts | 71 -- src/lib/extractTwitterMeta.ts | 20 - src/lib/extractYoutubeMeta.ts | 26 - src/lib/hooks/useAnimatedValue.ts | 12 + src/lib/hooks/useOnMainScroll.ts | 25 + src/lib/hooks/usePalette.ts | 48 + src/lib/icons.tsx | 529 ++++++++ src/lib/images.ts | 32 +- src/lib/images.web.ts | 5 +- src/lib/link-meta.ts | 1290 -------------------- src/lib/link-meta/bsky.ts | 99 ++ src/lib/link-meta/html.ts | 71 ++ src/lib/link-meta/link-meta.ts | 1290 ++++++++++++++++++++ src/lib/link-meta/twitter.ts | 20 + src/lib/link-meta/youtube.ts | 31 + src/lib/notifee.ts | 74 ++ src/lib/permissions.ts | 61 + src/lib/permissions.web.ts | 22 + src/lib/storage.ts | 52 + src/lib/strings.ts | 267 ---- src/lib/strings/errors.ts | 23 + src/lib/strings/handles.ts | 13 + src/lib/strings/helpers.ts | 17 + src/lib/strings/mention-manip.ts | 37 + src/lib/strings/rich-text-detection.ts | 107 ++ src/lib/strings/rich-text-sanitize.ts | 32 + src/lib/strings/rich-text.ts | 216 ++++ src/lib/strings/time.ts | 29 + src/lib/strings/url-helpers.ts | 108 ++ src/lib/styles.ts | 218 ++++ src/lib/themes.ts | 307 +++++ src/lib/type-guards.ts | 14 + src/state/index.ts | 40 +- src/state/lib/api-polyfill.ts | 79 -- src/state/lib/api-polyfill.web.ts | 4 - src/state/lib/api.ts | 190 --- src/state/lib/bg-scheduler.ts | 18 - src/state/lib/bg-scheduler.web.ts | 13 - src/state/lib/storage.ts | 52 - src/state/lib/type-guards.ts | 14 - src/state/models/feed-view.ts | 343 +++--- src/state/models/get-assertions-view.ts | 123 -- src/state/models/link-metas-view.ts | 2 +- src/state/models/log.ts | 2 +- src/state/models/me.ts | 68 +- src/state/models/my-follows.ts | 109 ++ src/state/models/navigation.ts | 55 +- src/state/models/notifications-view.ts | 334 +++-- src/state/models/onboard.ts | 2 +- src/state/models/post-thread-view.ts | 39 +- src/state/models/post.ts | 3 +- src/state/models/profile-view.ts | 62 +- src/state/models/profiles-view.ts | 4 +- src/state/models/reposted-by-view.ts | 69 +- src/state/models/root-store.ts | 198 ++- src/state/models/session.ts | 414 +++---- src/state/models/shell-ui.ts | 30 +- src/state/models/suggested-actors-view.ts | 173 ++- src/state/models/suggested-posts-view.ts | 148 +++ src/state/models/user-autocomplete-view.ts | 34 +- src/state/models/user-followers-view.ts | 61 +- src/state/models/user-follows-view.ts | 61 +- src/state/models/votes-view.ts | 62 +- src/view/com/composer/Autocomplete.tsx | 4 +- src/view/com/composer/ComposePost.tsx | 141 ++- src/view/com/composer/ExternalEmbed.tsx | 42 +- src/view/com/composer/Prompt.tsx | 2 +- src/view/com/composer/SelectedPhoto.tsx | 5 +- .../com/composer/char-progress/CharProgress.tsx | 2 +- .../composer/char-progress/CharProgress.web.tsx | 2 +- .../com/composer/photos/PhotoCarouselPicker.tsx | 112 +- .../composer/photos/PhotoCarouselPicker.web.tsx | 6 +- src/view/com/composer/text-input/TextInput.tsx | 14 +- src/view/com/composer/text-input/TextInput.web.tsx | 11 +- src/view/com/discover/LiteSuggestedFollows.tsx | 11 +- src/view/com/discover/SuggestedFollows.tsx | 208 +--- src/view/com/discover/SuggestedPosts.tsx | 66 + src/view/com/discover/WhoToFollow.tsx | 89 ++ src/view/com/lightbox/ImageViewing/index.tsx | 12 +- src/view/com/lightbox/Lightbox.tsx | 6 +- src/view/com/lightbox/Lightbox.web.tsx | 6 +- src/view/com/login/CreateAccount.tsx | 30 +- src/view/com/login/Logo.tsx | 2 +- src/view/com/login/Signin.tsx | 97 +- src/view/com/modals/Confirm.tsx | 7 +- src/view/com/modals/DeleteAccount.tsx | 210 ++++ src/view/com/modals/EditProfile.tsx | 51 +- src/view/com/modals/Modal.tsx | 33 +- src/view/com/modals/Modal.web.tsx | 18 +- src/view/com/modals/ReportAccount.tsx | 30 +- src/view/com/modals/ReportPost.tsx | 37 +- src/view/com/modals/ServerInput.tsx | 13 +- src/view/com/modals/crop-image/CropImage.web.tsx | 8 +- src/view/com/notifications/Feed.tsx | 82 +- src/view/com/notifications/FeedItem.tsx | 29 +- src/view/com/onboard/FeatureExplainer.tsx | 16 +- src/view/com/onboard/FeatureExplainer.web.tsx | 9 +- src/view/com/onboard/Follows.tsx | 12 +- src/view/com/onboard/Follows.web.tsx | 6 +- src/view/com/post-thread/PostRepostedBy.tsx | 69 +- src/view/com/post-thread/PostThread.tsx | 61 +- src/view/com/post-thread/PostThreadItem.tsx | 76 +- src/view/com/post-thread/PostVotedBy.tsx | 77 +- src/view/com/post/Post.tsx | 21 +- src/view/com/post/PostText.tsx | 4 +- src/view/com/posts/ComposerPrompt.tsx | 46 +- src/view/com/posts/ComposerPrompt.web.tsx | 4 +- src/view/com/posts/Feed.tsx | 157 ++- src/view/com/posts/FeedItem.tsx | 27 +- src/view/com/profile/ProfileCard.tsx | 138 ++- src/view/com/profile/ProfileFollowers.tsx | 68 +- src/view/com/profile/ProfileFollows.tsx | 75 +- src/view/com/profile/ProfileHeader.tsx | 57 +- src/view/com/util/BlurView.web.tsx | 2 +- src/view/com/util/EmptyState.tsx | 4 +- src/view/com/util/FAB.tsx | 55 +- src/view/com/util/Link.tsx | 16 +- src/view/com/util/LoadLatestBtn.tsx | 4 +- src/view/com/util/LoadLatestBtn.web.tsx | 2 +- src/view/com/util/LoadingPlaceholder.tsx | 8 +- src/view/com/util/Picker.tsx | 2 +- src/view/com/util/PostCtrls.tsx | 193 +-- src/view/com/util/PostEmbeds.tsx | 158 --- src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx | 69 ++ src/view/com/util/PostEmbeds/YoutubeEmbed.tsx | 119 ++ src/view/com/util/PostEmbeds/index.tsx | 139 +++ src/view/com/util/PostMeta.tsx | 4 +- src/view/com/util/Selector.tsx | 2 +- src/view/com/util/UserAvatar.tsx | 115 +- src/view/com/util/UserBanner.tsx | 97 +- src/view/com/util/UserInfoText.tsx | 4 +- src/view/com/util/ViewHeader.tsx | 152 +-- src/view/com/util/ViewHeader.web.tsx | 46 +- src/view/com/util/ViewSelector.tsx | 9 +- src/view/com/util/Views.web.tsx | 5 +- src/view/com/util/anim/TriggerableAnimated.tsx | 73 ++ src/view/com/util/error/ErrorMessage.tsx | 4 +- src/view/com/util/error/ErrorScreen.tsx | 6 +- src/view/com/util/forms/Button.tsx | 4 +- src/view/com/util/forms/DropdownButton.tsx | 113 +- src/view/com/util/forms/RadioButton.tsx | 4 +- src/view/com/util/forms/RadioGroup.tsx | 2 +- src/view/com/util/forms/ToggleButton.tsx | 6 +- src/view/com/util/gestures/HorzSwipe.tsx | 5 +- src/view/com/util/gestures/SwipeAndZoom.tsx | 2 +- src/view/com/util/images/AutoSizedImage.tsx | 138 +-- src/view/com/util/images/Image.tsx | 12 + src/view/com/util/images/Image.web.tsx | 11 + src/view/com/util/images/ImageHorzList.tsx | 2 +- src/view/com/util/images/ImageLayoutGrid.tsx | 36 +- src/view/com/util/images/constants.ts | 1 - .../images/image-crop-picker/ImageCropPicker.tsx | 2 +- .../image-crop-picker/ImageCropPicker.web.tsx | 12 +- src/view/com/util/text/RichText.tsx | 28 +- src/view/com/util/text/Text.tsx | 4 +- src/view/index.ts | 4 + src/view/lib/ThemeContext.tsx | 93 -- src/view/lib/addStyle.ts | 11 - src/view/lib/assets.native.ts | 5 - src/view/lib/assets.ts | 7 - src/view/lib/hooks/useAnimatedValue.ts | 12 - src/view/lib/hooks/useOnMainScroll.ts | 25 - src/view/lib/hooks/usePalette.ts | 48 - src/view/lib/icons.tsx | 529 -------- src/view/lib/notifee.ts | 54 - src/view/lib/styles.ts | 208 ---- src/view/lib/themes.ts | 297 ----- src/view/routes.ts | 4 +- src/view/screens/Contacts.tsx | 6 +- src/view/screens/Debug.tsx | 15 +- src/view/screens/Home.tsx | 58 +- src/view/screens/Log.tsx | 8 +- src/view/screens/Login.tsx | 47 +- src/view/screens/Login.web.tsx | 14 +- src/view/screens/NotFound.tsx | 2 +- src/view/screens/Notifications.tsx | 81 +- src/view/screens/Onboard.tsx | 4 +- src/view/screens/PostDownvotedBy.tsx | 4 +- src/view/screens/PostRepostedBy.tsx | 4 +- src/view/screens/PostThread.tsx | 14 +- src/view/screens/PostUpvotedBy.tsx | 4 +- src/view/screens/Profile.tsx | 19 +- src/view/screens/ProfileFollowers.tsx | 4 +- src/view/screens/ProfileFollows.tsx | 4 +- src/view/screens/Search.tsx | 232 ++-- src/view/screens/Settings.tsx | 40 +- src/view/shell/mobile/Composer.tsx | 6 +- src/view/shell/mobile/Menu.tsx | 77 +- src/view/shell/mobile/TabsSelector.tsx | 8 +- src/view/shell/mobile/index.tsx | 123 +- src/view/shell/web/Composer.tsx | 4 +- src/view/shell/web/DesktopLeftColumn.tsx | 10 +- src/view/shell/web/DesktopRightColumn.tsx | 6 +- src/view/shell/web/index.tsx | 8 +- 213 files changed, 8422 insertions(+), 6393 deletions(-) delete mode 100644 src/build-flags.ts create mode 100644 src/lib/ThemeContext.tsx create mode 100644 src/lib/analytics.tsx create mode 100644 src/lib/analytics.web.tsx create mode 100644 src/lib/api/api-polyfill.ts create mode 100644 src/lib/api/api-polyfill.web.ts create mode 100644 src/lib/api/index.ts create mode 100644 src/lib/app-info.ts create mode 100644 src/lib/app-info.web.ts create mode 100644 src/lib/assets.native.ts create mode 100644 src/lib/assets.ts create mode 100644 src/lib/async/bundle.ts create mode 100644 src/lib/bg-scheduler.ts create mode 100644 src/lib/bg-scheduler.web.ts create mode 100644 src/lib/build-flags.ts create mode 100644 src/lib/constants.ts delete mode 100644 src/lib/errors.ts delete mode 100644 src/lib/extractBskyMeta.ts delete mode 100644 src/lib/extractHtmlMeta.ts delete mode 100644 src/lib/extractTwitterMeta.ts delete mode 100644 src/lib/extractYoutubeMeta.ts create mode 100644 src/lib/hooks/useAnimatedValue.ts create mode 100644 src/lib/hooks/useOnMainScroll.ts create mode 100644 src/lib/hooks/usePalette.ts create mode 100644 src/lib/icons.tsx delete mode 100644 src/lib/link-meta.ts create mode 100644 src/lib/link-meta/bsky.ts create mode 100644 src/lib/link-meta/html.ts create mode 100644 src/lib/link-meta/link-meta.ts create mode 100644 src/lib/link-meta/twitter.ts create mode 100644 src/lib/link-meta/youtube.ts create mode 100644 src/lib/notifee.ts create mode 100644 src/lib/permissions.ts create mode 100644 src/lib/permissions.web.ts create mode 100644 src/lib/storage.ts delete mode 100644 src/lib/strings.ts create mode 100644 src/lib/strings/errors.ts create mode 100644 src/lib/strings/handles.ts create mode 100644 src/lib/strings/helpers.ts create mode 100644 src/lib/strings/mention-manip.ts create mode 100644 src/lib/strings/rich-text-detection.ts create mode 100644 src/lib/strings/rich-text-sanitize.ts create mode 100644 src/lib/strings/rich-text.ts create mode 100644 src/lib/strings/time.ts create mode 100644 src/lib/strings/url-helpers.ts create mode 100644 src/lib/styles.ts create mode 100644 src/lib/themes.ts create mode 100644 src/lib/type-guards.ts delete mode 100644 src/state/lib/api-polyfill.ts delete mode 100644 src/state/lib/api-polyfill.web.ts delete mode 100644 src/state/lib/api.ts delete mode 100644 src/state/lib/bg-scheduler.ts delete mode 100644 src/state/lib/bg-scheduler.web.ts delete mode 100644 src/state/lib/storage.ts delete mode 100644 src/state/lib/type-guards.ts delete mode 100644 src/state/models/get-assertions-view.ts create mode 100644 src/state/models/my-follows.ts create mode 100644 src/state/models/suggested-posts-view.ts create mode 100644 src/view/com/discover/SuggestedPosts.tsx create mode 100644 src/view/com/discover/WhoToFollow.tsx create mode 100644 src/view/com/modals/DeleteAccount.tsx delete mode 100644 src/view/com/util/PostEmbeds.tsx create mode 100644 src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx create mode 100644 src/view/com/util/PostEmbeds/YoutubeEmbed.tsx create mode 100644 src/view/com/util/PostEmbeds/index.tsx create mode 100644 src/view/com/util/anim/TriggerableAnimated.tsx create mode 100644 src/view/com/util/images/Image.tsx create mode 100644 src/view/com/util/images/Image.web.tsx delete mode 100644 src/view/com/util/images/constants.ts delete mode 100644 src/view/lib/ThemeContext.tsx delete mode 100644 src/view/lib/addStyle.ts delete mode 100644 src/view/lib/assets.native.ts delete mode 100644 src/view/lib/assets.ts delete mode 100644 src/view/lib/hooks/useAnimatedValue.ts delete mode 100644 src/view/lib/hooks/useOnMainScroll.ts delete mode 100644 src/view/lib/hooks/usePalette.ts delete mode 100644 src/view/lib/icons.tsx delete mode 100644 src/view/lib/notifee.ts delete mode 100644 src/view/lib/styles.ts delete mode 100644 src/view/lib/themes.ts (limited to 'src') diff --git a/src/App.native.tsx b/src/App.native.tsx index f00e3cad1..17b8b4552 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -6,35 +6,27 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler' import SplashScreen from 'react-native-splash-screen' import {SafeAreaProvider} from 'react-native-safe-area-context' import {observer} from 'mobx-react-lite' -import { - createClient, - SegmentClient, - AnalyticsProvider, -} from '@segment/analytics-react-native' -import {ThemeProvider} from './view/lib/ThemeContext' +import {ThemeProvider} from 'lib/ThemeContext' import * as view from './view/index' import {RootStoreModel, setupState, RootStoreProvider} from './state' import {MobileShell} from './view/shell/mobile' -import {s} from './view/lib/styles' -import notifee, {EventType} from '@notifee/react-native' +import {s} from 'lib/styles' +import * as notifee from 'lib/notifee' +import * as analytics from 'lib/analytics' +import * as Toast from './view/com/util/Toast' const App = observer(() => { const [rootStore, setRootStore] = useState( undefined, ) - const [segment, setSegment] = useState(undefined) // init useEffect(() => { view.setup() - setSegment( - createClient({ - writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI', - trackAppLifecycleEvents: true, - }), - ) setupState().then(store => { setRootStore(store) + analytics.init(store) + notifee.init(store) SplashScreen.hide() Linking.getInitialURL().then((url: string | null) => { if (url) { @@ -44,12 +36,8 @@ const App = observer(() => { Linking.addEventListener('url', ({url}) => { store.nav.handleLink(url) }) - notifee.onForegroundEvent(async ({type}: {type: EventType}) => { - store.log.debug('Notifee foreground event', {type}) - if (type === EventType.PRESS) { - store.log.debug('User pressed a notifee, opening notifications') - store.nav.switchTo(1, true) - } + store.onSessionDropped(() => { + Toast.show('Sorry! Your session expired. Please log in again.') }) }) }, []) @@ -58,20 +46,19 @@ const App = observer(() => { if (!rootStore) { return null } - return ( - - - - + + + + - - - - + + + + ) }) diff --git a/src/build-flags.ts b/src/build-flags.ts deleted file mode 100644 index 155230e5d..000000000 --- a/src/build-flags.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const LOGIN_INCLUDE_DEV_SERVERS = true -export const TABS_ENABLED = false diff --git a/src/lib/ThemeContext.tsx b/src/lib/ThemeContext.tsx new file mode 100644 index 000000000..bcfc076f4 --- /dev/null +++ b/src/lib/ThemeContext.tsx @@ -0,0 +1,95 @@ +import React, {createContext, useContext, useMemo} from 'react' +import {TextStyle, useColorScheme, ViewStyle} from 'react-native' +import {darkTheme, defaultTheme} from './themes' + +export type ColorScheme = 'light' | 'dark' + +export type PaletteColorName = + | 'default' + | 'primary' + | 'secondary' + | 'inverted' + | 'error' +export type PaletteColor = { + background: string + backgroundLight: string + text: string + textLight: string + textInverted: string + link: string + border: string + borderDark: string + icon: string + [k: string]: string +} +export type Palette = Record + +export type ShapeName = 'button' | 'bigButton' | 'smallButton' +export type Shapes = Record + +export type TypographyVariant = + | 'xl-thin' + | 'xl' + | 'xl-medium' + | 'xl-bold' + | 'xl-heavy' + | 'lg-thin' + | 'lg' + | 'lg-medium' + | 'lg-bold' + | 'lg-heavy' + | 'md-thin' + | 'md' + | 'md-medium' + | 'md-bold' + | 'md-heavy' + | 'sm-thin' + | 'sm' + | 'sm-medium' + | 'sm-bold' + | 'sm-heavy' + | 'xs-thin' + | 'xs' + | 'xs-medium' + | 'xs-bold' + | 'xs-heavy' + | 'title-2xl' + | 'title-xl' + | 'title-lg' + | 'title' + | 'title-sm' + | 'post-text-lg' + | 'post-text' + | 'button' + | 'button-lg' + | 'mono' +export type Typography = Record + +export interface Theme { + colorScheme: ColorScheme + palette: Palette + shapes: Shapes + typography: Typography +} + +export interface ThemeProviderProps { + theme?: ColorScheme +} + +export const ThemeContext = createContext(defaultTheme) + +export const useTheme = () => useContext(ThemeContext) + +export const ThemeProvider: React.FC = ({ + theme, + children, +}) => { + const colorScheme = useColorScheme() + + const value = useMemo( + () => ((theme || colorScheme) === 'dark' ? darkTheme : defaultTheme), + [colorScheme, theme], + ) + + return {children} +} diff --git a/src/lib/analytics.tsx b/src/lib/analytics.tsx new file mode 100644 index 000000000..441cdc454 --- /dev/null +++ b/src/lib/analytics.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import {AppState, AppStateStatus} from 'react-native' +import {createClient, AnalyticsProvider} from '@segment/analytics-react-native' +import {RootStoreModel, AppInfo} from 'state/models/root-store' + +const segmentClient = createClient({ + writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI', + trackAppLifecycleEvents: false, +}) + +export {useAnalytics} from '@segment/analytics-react-native' + +export function init(store: RootStoreModel) { + // NOTE + // this method 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.onContextLoaded(() => { + if (AppState.currentState !== 'active') { + store.log.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') + return + } + + const oldAppInfo = store.appInfo + const newAppInfo = context.app as AppInfo + store.setAppInfo(newAppInfo) + store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo}) + + if (typeof oldAppInfo === 'undefined') { + 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 + AppState.addEventListener('change', (state: AppStateStatus) => { + if (state === 'active' && lastState !== 'active') { + const context = segmentClient.context.get() + segmentClient.track('Application Opened', { + from_background: true, + version: context?.app?.version, + build: context?.app?.build, + }) + } else if (state !== 'active' && lastState === 'active') { + segmentClient.track('Application Backgrounded') + } + lastState = state + }) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + return ( + {children} + ) +} diff --git a/src/lib/analytics.web.tsx b/src/lib/analytics.web.tsx new file mode 100644 index 000000000..d7b9b2646 --- /dev/null +++ b/src/lib/analytics.web.tsx @@ -0,0 +1,16 @@ +// TODO +import React from 'react' +import {RootStoreModel} from 'state/models/root-store' + +export function useAnalytics() { + return { + screen(_name: string) {}, + track(_name: string, _opts: any) {}, + } +} + +export function init(_store: RootStoreModel) {} + +export function Provider({children}: React.PropsWithChildren<{}>) { + return children +} diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts new file mode 100644 index 000000000..3b5ba7518 --- /dev/null +++ b/src/lib/api/api-polyfill.ts @@ -0,0 +1,79 @@ +import AtpAgent from '@atproto/api' +import RNFS from 'react-native-fs' + +const TIMEOUT = 10e3 // 10s + +export function doPolyfill() { + AtpAgent.configure({fetch: fetchHandler}) +} + +interface FetchHandlerResponse { + status: number + headers: Record + body: ArrayBuffer | undefined +} + +async function fetchHandler( + reqUri: string, + reqMethod: string, + reqHeaders: Record, + reqBody: any, +): Promise { + const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type'] + if (reqMimeType && reqMimeType.startsWith('application/json')) { + reqBody = JSON.stringify(reqBody) + } else if ( + typeof reqBody === 'string' && + (reqBody.startsWith('/') || reqBody.startsWith('file:')) + ) { + if (reqBody.endsWith('.jpeg') || reqBody.endsWith('.jpg')) { + // HACK + // React native has a bug that inflates the size of jpegs on upload + // we get around that by renaming the file ext to .bin + // see https://github.com/facebook/react-native/issues/27099 + // -prf + const newPath = reqBody.replace(/\.jpe?g$/, '.bin') + await RNFS.moveFile(reqBody, newPath) + reqBody = newPath + } + // NOTE + // React native treats bodies with {uri: string} as file uploads to pull from cache + // -prf + reqBody = {uri: reqBody} + } + + const controller = new AbortController() + const to = setTimeout(() => controller.abort(), TIMEOUT) + + const res = await fetch(reqUri, { + method: reqMethod, + headers: reqHeaders, + body: reqBody, + signal: controller.signal, + }) + + const resStatus = res.status + const resHeaders: Record = {} + res.headers.forEach((value: string, key: string) => { + resHeaders[key] = value + }) + const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type'] + let resBody + if (resMimeType) { + if (resMimeType.startsWith('application/json')) { + resBody = await res.json() + } else if (resMimeType.startsWith('text/')) { + resBody = await res.text() + } else { + throw new Error('TODO: non-textual response body') + } + } + + clearTimeout(to) + + return { + status: resStatus, + headers: resHeaders, + body: resBody, + } +} diff --git a/src/lib/api/api-polyfill.web.ts b/src/lib/api/api-polyfill.web.ts new file mode 100644 index 000000000..1469cf905 --- /dev/null +++ b/src/lib/api/api-polyfill.web.ts @@ -0,0 +1,4 @@ +export function doPolyfill() { + // TODO needed? native fetch may work fine -prf + // AtpApi.xrpc.fetch = fetchHandler +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 000000000..d800c376c --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,201 @@ +import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' +import {AtUri} from '../../third-party/uri' +import {RootStoreModel} from 'state/models/root-store' +import {extractEntities} from 'lib/strings/rich-text-detection' +import {isNetworkError} from 'lib/strings/errors' +import {LinkMeta} from '../link-meta/link-meta' +import {Image} from '../images' +import {RichText} from '../strings/rich-text' + +export interface ExternalEmbedDraft { + uri: string + isLoading: boolean + meta?: LinkMeta + localThumb?: Image +} + +export async function resolveName(store: RootStoreModel, didOrHandle: string) { + if (!didOrHandle) { + throw new Error('Invalid handle: ""') + } + if (didOrHandle.startsWith('did:')) { + return didOrHandle + } + const res = await store.api.com.atproto.handle.resolve({ + handle: didOrHandle, + }) + return res.data.did +} + +export async function post( + store: RootStoreModel, + rawText: string, + replyTo?: string, + extLink?: ExternalEmbedDraft, + images?: string[], + knownHandles?: Set, + onStateChange?: (state: string) => void, +) { + let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined + let reply + const text = new RichText(rawText, undefined, { + cleanNewlines: true, + }).text.trim() + + onStateChange?.('Processing...') + const entities = extractEntities(text, knownHandles) + if (entities) { + for (const ent of entities) { + if (ent.type === 'mention') { + const prof = await store.profiles.getProfile(ent.value) + ent.value = prof.data.did + } + } + } + + if (images?.length) { + embed = { + $type: 'app.bsky.embed.images', + images: [], + } as AppBskyEmbedImages.Main + let i = 1 + for (const image of images) { + onStateChange?.(`Uploading image #${i++}...`) + const res = await store.api.com.atproto.blob.upload( + image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts + {encoding: 'image/jpeg'}, + ) + embed.images.push({ + image: { + cid: res.data.cid, + mimeType: 'image/jpeg', + }, + alt: '', // TODO supply alt text + }) + } + } + + if (!embed && extLink) { + let thumb + if (extLink.localThumb) { + onStateChange?.('Uploading link thumbnail...') + let encoding + if (extLink.localThumb.path.endsWith('.png')) { + encoding = 'image/png' + } else if ( + extLink.localThumb.path.endsWith('.jpeg') || + extLink.localThumb.path.endsWith('.jpg') + ) { + encoding = 'image/jpeg' + } else { + store.log.warn( + 'Unexpected image format for thumbnail, skipping', + extLink.localThumb.path, + ) + } + if (encoding) { + const thumbUploadRes = await store.api.com.atproto.blob.upload( + extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts + {encoding}, + ) + thumb = { + cid: thumbUploadRes.data.cid, + mimeType: encoding, + } + } + } + embed = { + $type: 'app.bsky.embed.external', + external: { + uri: extLink.uri, + title: extLink.meta?.title || '', + description: extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main + } + + if (replyTo) { + const replyToUrip = new AtUri(replyTo) + const parentPost = await store.api.app.bsky.feed.post.get({ + user: replyToUrip.host, + rkey: replyToUrip.rkey, + }) + if (parentPost) { + const parentRef = { + uri: parentPost.uri, + cid: parentPost.cid, + } + reply = { + root: parentPost.value.reply?.root || parentRef, + parent: parentRef, + } + } + } + + try { + onStateChange?.('Posting...') + return await store.api.app.bsky.feed.post.create( + {did: store.me.did || ''}, + { + text, + reply, + embed, + entities, + createdAt: new Date().toISOString(), + }, + ) + } catch (e: any) { + console.error(`Failed to create post: ${e.toString()}`) + if (isNetworkError(e)) { + throw new Error( + 'Post failed to upload. Please check your Internet connection and try again.', + ) + } else { + throw e + } + } +} + +export async function repost(store: RootStoreModel, uri: string, cid: string) { + return await store.api.app.bsky.feed.repost.create( + {did: store.me.did || ''}, + { + subject: {uri, cid}, + createdAt: new Date().toISOString(), + }, + ) +} + +export async function unrepost(store: RootStoreModel, repostUri: string) { + const repostUrip = new AtUri(repostUri) + return await store.api.app.bsky.feed.repost.delete({ + did: repostUrip.hostname, + rkey: repostUrip.rkey, + }) +} + +export async function follow( + store: RootStoreModel, + subjectDid: string, + subjectDeclarationCid: string, +) { + return await store.api.app.bsky.graph.follow.create( + {did: store.me.did || ''}, + { + subject: { + did: subjectDid, + declarationCid: subjectDeclarationCid, + }, + createdAt: new Date().toISOString(), + }, + ) +} + +export async function unfollow(store: RootStoreModel, followUri: string) { + const followUrip = new AtUri(followUri) + return await store.api.app.bsky.graph.follow.delete({ + did: followUrip.hostname, + rkey: followUrip.rkey, + }) +} diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts new file mode 100644 index 000000000..1ced274e7 --- /dev/null +++ b/src/lib/app-info.ts @@ -0,0 +1,4 @@ +import VersionNumber from 'react-native-version-number' + +export const appVersion = VersionNumber.appVersion +export const buildVersion = VersionNumber.buildVersion diff --git a/src/lib/app-info.web.ts b/src/lib/app-info.web.ts new file mode 100644 index 000000000..a2b6858da --- /dev/null +++ b/src/lib/app-info.web.ts @@ -0,0 +1,3 @@ +// TODO +export const appVersion = 'TODO' +export const buildVersion = 'TODO' diff --git a/src/lib/assets.native.ts b/src/lib/assets.native.ts new file mode 100644 index 000000000..d7f4a7287 --- /dev/null +++ b/src/lib/assets.native.ts @@ -0,0 +1,5 @@ +import {ImageRequireSource} from 'react-native' + +export const DEF_AVATAR: ImageRequireSource = require('../../public/img/default-avatar.jpg') +export const TABS_EXPLAINER: ImageRequireSource = require('../../public/img/tabs-explainer.jpg') +export const CLOUD_SPLASH: ImageRequireSource = require('../../public/img/cloud-splash.png') diff --git a/src/lib/assets.ts b/src/lib/assets.ts new file mode 100644 index 000000000..216478762 --- /dev/null +++ b/src/lib/assets.ts @@ -0,0 +1,10 @@ +import {ImageRequireSource} from 'react-native' + +// @ts-ignore we need to pretend -prf +export const DEF_AVATAR: ImageRequireSource = {uri: '/img/default-avatar.jpg'} +// @ts-ignore we need to pretend -prf +export const TABS_EXPLAINER: ImageRequireSource = { + uri: '/img/tabs-explainer.jpg', +} +// @ts-ignore we need to pretend -prf +export const CLOUD_SPLASH: ImageRequireSource = {uri: '/img/cloud-splash.png'} diff --git a/src/lib/async/bundle.ts b/src/lib/async/bundle.ts new file mode 100644 index 000000000..e307cd437 --- /dev/null +++ b/src/lib/async/bundle.ts @@ -0,0 +1,24 @@ +type BundledFn = ( + ...args: Args +) => Promise + +/** + * A helper which ensures that multiple calls to an async function + * only produces one in-flight request at a time. + */ +export function bundleAsync( + fn: BundledFn, +): BundledFn { + let promise: Promise | undefined + return async (...args) => { + if (promise) { + return promise + } + promise = fn(...args) + try { + return await promise + } finally { + promise = undefined + } + } +} diff --git a/src/lib/bg-scheduler.ts b/src/lib/bg-scheduler.ts new file mode 100644 index 000000000..db3f2d7fd --- /dev/null +++ b/src/lib/bg-scheduler.ts @@ -0,0 +1,18 @@ +import BackgroundFetch, { + BackgroundFetchStatus, +} from 'react-native-background-fetch' + +export function configure( + handler: (taskId: string) => Promise, + timeoutHandler: (taskId: string) => void, +): Promise { + return BackgroundFetch.configure( + {minimumFetchInterval: 15}, + handler, + timeoutHandler, + ) +} + +export function finish(taskId: string) { + return BackgroundFetch.finish(taskId) +} diff --git a/src/lib/bg-scheduler.web.ts b/src/lib/bg-scheduler.web.ts new file mode 100644 index 000000000..91ec9428f --- /dev/null +++ b/src/lib/bg-scheduler.web.ts @@ -0,0 +1,13 @@ +type BackgroundFetchStatus = 0 | 1 | 2 + +export async function configure( + _handler: (taskId: string) => Promise, + _timeoutHandler: (taskId: string) => Promise, +): Promise { + // TODO + return 0 +} + +export function finish(_taskId: string) { + // TODO +} diff --git a/src/lib/build-flags.ts b/src/lib/build-flags.ts new file mode 100644 index 000000000..155230e5d --- /dev/null +++ b/src/lib/build-flags.ts @@ -0,0 +1,2 @@ +export const LOGIN_INCLUDE_DEV_SERVERS = true +export const TABS_ENABLED = false diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 000000000..2a3043c06 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,65 @@ +export const FEEDBACK_FORM_URL = + 'https://docs.google.com/forms/d/e/1FAIpQLSdavFRXTdB6tRobaFrRR2A1gv3b-IBHwQkBmNZTRpoqmcrPrQ/viewform?usp=sf_link' + +export const MAX_DISPLAY_NAME = 64 +export const MAX_DESCRIPTION = 256 + +export const PROD_SUGGESTED_FOLLOWS = [ + 'john', + 'visakanv', + 'saz', + 'steph', + 'ratzlaff', + 'beth', + 'weisser', + 'katherine', + 'annagat', + 'josh', + 'lurkshark', + 'amir', + 'amyxzh', + 'danielle', + 'jack-frazee', + 'vibes', + 'cat', + 'yuriy', + 'alvinreyes', + 'skoot', + 'patricia', + 'ara4n', + 'case', + 'armand', + 'ivan', + 'nicholas', + 'kelsey', + 'ericlee', + 'emily', + 'jake', + 'jennijuju', + 'ian5v', + 'bnewbold', + 'chris', + 'mtclai', + 'willscott', + 'michael', + 'kwkroeger', + 'broox', + 'iamrosewang', + 'jack-morrison', + 'pwang', + 'martin', + 'jack', + 'dan', + 'why', + 'divy', + 'jay', + 'paul', +].map(handle => `${handle}.bsky.social`) + +export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map( + handle => `${handle}.staging.bsky.dev`, +) + +export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map( + handle => `${handle}.test`, +) diff --git a/src/lib/errors.ts b/src/lib/errors.ts deleted file mode 100644 index 216d5927b..000000000 --- a/src/lib/errors.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function isNetworkError(e: unknown) { - const str = String(e) - return str.includes('Abort') || str.includes('Network request failed') -} diff --git a/src/lib/extractBskyMeta.ts b/src/lib/extractBskyMeta.ts deleted file mode 100644 index e53036aec..000000000 --- a/src/lib/extractBskyMeta.ts +++ /dev/null @@ -1,99 +0,0 @@ -import {LikelyType, LinkMeta} from './link-meta' -import {match as matchRoute} from '../view/routes' -import {convertBskyAppUrlIfNeeded, makeRecordUri} from './strings' -import {RootStoreModel} from '../state' -import {PostThreadViewModel} from '../state/models/post-thread-view' - -import {Home} from '../view/screens/Home' -import {Search} from '../view/screens/Search' -import {Notifications} from '../view/screens/Notifications' -import {PostThread} from '../view/screens/PostThread' -import {PostUpvotedBy} from '../view/screens/PostUpvotedBy' -import {PostRepostedBy} from '../view/screens/PostRepostedBy' -import {Profile} from '../view/screens/Profile' -import {ProfileFollowers} from '../view/screens/ProfileFollowers' -import {ProfileFollows} from '../view/screens/ProfileFollows' - -// NOTE -// this is a hack around the lack of hosted social metadata -// remove once that's implemented -// -prf -export async function extractBskyMeta( - store: RootStoreModel, - url: string, -): Promise { - url = convertBskyAppUrlIfNeeded(url) - const route = matchRoute(url) - let meta: LinkMeta = { - likelyType: LikelyType.AtpData, - url, - title: route.defaultTitle, - } - - if (route.Com === Home) { - meta = { - ...meta, - title: 'Bluesky', - description: 'A new kind of social network', - } - } else if (route.Com === Search) { - meta = { - ...meta, - title: 'Search - Bluesky', - description: 'A new kind of social network', - } - } else if (route.Com === Notifications) { - meta = { - ...meta, - title: 'Notifications - Bluesky', - description: 'A new kind of social network', - } - } else if ( - route.Com === PostThread || - route.Com === PostUpvotedBy || - route.Com === PostRepostedBy - ) { - // post and post-related screens - const threadUri = makeRecordUri( - route.params.name, - 'app.bsky.feed.post', - route.params.rkey, - ) - const threadView = new PostThreadViewModel(store, { - uri: threadUri, - depth: 0, - }) - await threadView.setup().catch(_err => undefined) - const title = [ - route.Com === PostUpvotedBy - ? 'Likes on a post by' - : route.Com === PostRepostedBy - ? 'Reposts of a post by' - : 'Post by', - threadView.thread?.post.author.displayName || - threadView.thread?.post.author.handle || - 'a bluesky user', - ].join(' ') - meta = { - ...meta, - title, - description: threadView.thread?.postRecord?.text, - } - } else if ( - route.Com === Profile || - route.Com === ProfileFollowers || - route.Com === ProfileFollows - ) { - // profile and profile-related screens - const profile = await store.profiles.getProfile(route.params.name) - if (profile?.data) { - meta = { - ...meta, - title: profile.data.displayName || profile.data.handle, - description: profile.data.description, - } - } - } - - return meta -} diff --git a/src/lib/extractHtmlMeta.ts b/src/lib/extractHtmlMeta.ts deleted file mode 100644 index 70387f71d..000000000 --- a/src/lib/extractHtmlMeta.ts +++ /dev/null @@ -1,71 +0,0 @@ -import {extractTwitterMeta} from './extractTwitterMeta' -import {extractYoutubeMeta} from './extractYoutubeMeta' - -interface ExtractHtmlMetaInput { - html: string - hostname?: string - pathname?: string -} - -export const extractHtmlMeta = ({ - html, - hostname, - pathname, -}: ExtractHtmlMetaInput): Record => { - const htmlTitleRegex = /([^<]+)<\/title>/i - - let res: Record = {} - - const match = htmlTitleRegex.exec(html) - - if (match) { - res.title = match[1].trim() - } - - let metaMatch - let propMatch - const metaRe = /]+)>/gis - while ((metaMatch = metaRe.exec(html))) { - let propName - let propValue - const propRe = /(name|property|content)="([^"]+)"/gis - while ((propMatch = propRe.exec(metaMatch[1]))) { - if (propMatch[1] === 'content') { - propValue = propMatch[2] - } else { - propName = propMatch[2] - } - } - if (!propName || !propValue) { - continue - } - switch (propName?.trim()) { - case 'title': - case 'og:title': - case 'twitter:title': - res.title = propValue?.trim() - break - case 'description': - case 'og:description': - case 'twitter:description': - res.description = propValue?.trim() - break - case 'og:image': - case 'twitter:image': - res.image = propValue?.trim() - break - } - } - - const isYoutubeUrl = - hostname?.includes('youtube.') || hostname?.includes('youtu.be') - const isTwitterUrl = hostname?.includes('twitter.') - // Workaround for some websites not having a title or description in the meta tags in the initial serve - if (isYoutubeUrl) { - res = {...res, ...extractYoutubeMeta(html)} - } else if (isTwitterUrl && pathname) { - res = {...extractTwitterMeta({pathname})} - } - - return res -} diff --git a/src/lib/extractTwitterMeta.ts b/src/lib/extractTwitterMeta.ts deleted file mode 100644 index d785903c0..000000000 --- a/src/lib/extractTwitterMeta.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const extractTwitterMeta = ({ - pathname, -}: { - pathname: string -}): Record => { - const res = {title: 'Twitter'} - const parsedPathname = pathname.split('/') - if (parsedPathname.length <= 1 || parsedPathname[1].length <= 1) { - // Excluding one letter usernames as they're reserved by twitter for things like cases like twitter.com/i/articles/follows/-1675653703 - return res - } - const username = parsedPathname?.[1] - const isUserProfile = parsedPathname?.length === 2 - - res.title = isUserProfile - ? `@${username} on Twitter` - : `Tweet by @${username}` - - return res -} diff --git a/src/lib/extractYoutubeMeta.ts b/src/lib/extractYoutubeMeta.ts deleted file mode 100644 index 566e3be46..000000000 --- a/src/lib/extractYoutubeMeta.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const extractYoutubeMeta = (html: string): Record => { - const res: Record = {} - const youtubeTitleRegex = /"videoDetails":.*"title":"([^"]*)"/i - const youtubeDescriptionRegex = - /"videoDetails":.*"shortDescription":"([^"]*)"/i - const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i - - const youtubeTitleMatch = youtubeTitleRegex.exec(html) - const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html) - const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html) - - if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) { - res.title = decodeURI(youtubeTitleMatch[1]) - } - if (youtubeDescriptionMatch && youtubeDescriptionMatch.length >= 1) { - res.description = decodeURI(youtubeDescriptionMatch[1]).replace( - /\\n/g, - '\n', - ) - } - if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) { - res.image = youtubeThumbnailMatch[1] + 'default.jpg' - } - - return res -} diff --git a/src/lib/hooks/useAnimatedValue.ts b/src/lib/hooks/useAnimatedValue.ts new file mode 100644 index 000000000..1307ef952 --- /dev/null +++ b/src/lib/hooks/useAnimatedValue.ts @@ -0,0 +1,12 @@ +import * as React from 'react' +import {Animated} from 'react-native' + +export function useAnimatedValue(initialValue: number) { + const lazyRef = React.useRef() + + if (lazyRef.current === undefined) { + lazyRef.current = new Animated.Value(initialValue) + } + + return lazyRef.current as Animated.Value +} diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts new file mode 100644 index 000000000..41b35dd4f --- /dev/null +++ b/src/lib/hooks/useOnMainScroll.ts @@ -0,0 +1,25 @@ +import {useState} from 'react' +import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' +import {RootStoreModel} from 'state/index' + +export type OnScrollCb = ( + event: NativeSyntheticEvent, +) => void + +export function useOnMainScroll(store: RootStoreModel) { + let [lastY, setLastY] = useState(0) + let isMinimal = store.shell.minimalShellMode + return function onMainScroll(event: NativeSyntheticEvent) { + const y = event.nativeEvent.contentOffset.y + const dy = y - (lastY || 0) + setLastY(y) + + if (!isMinimal && y > 10 && dy > 10) { + store.shell.setMinimalShellMode(true) + isMinimal = true + } else if (isMinimal && (y <= 10 || dy < -10)) { + store.shell.setMinimalShellMode(false) + isMinimal = false + } + } +} diff --git a/src/lib/hooks/usePalette.ts b/src/lib/hooks/usePalette.ts new file mode 100644 index 000000000..5b9929c7d --- /dev/null +++ b/src/lib/hooks/usePalette.ts @@ -0,0 +1,48 @@ +import {TextStyle, ViewStyle} from 'react-native' +import {useTheme, PaletteColorName, PaletteColor} from '../ThemeContext' + +export interface UsePaletteValue { + colors: PaletteColor + view: ViewStyle + btn: ViewStyle + border: ViewStyle + borderDark: ViewStyle + text: TextStyle + textLight: TextStyle + textInverted: TextStyle + link: TextStyle + icon: TextStyle +} +export function usePalette(color: PaletteColorName): UsePaletteValue { + const palette = useTheme().palette[color] + return { + colors: palette, + view: { + backgroundColor: palette.background, + }, + btn: { + backgroundColor: palette.backgroundLight, + }, + border: { + borderColor: palette.border, + }, + borderDark: { + borderColor: palette.borderDark, + }, + text: { + color: palette.text, + }, + textLight: { + color: palette.textLight, + }, + textInverted: { + color: palette.textInverted, + }, + link: { + color: palette.link, + }, + icon: { + color: palette.icon, + }, + } +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx new file mode 100644 index 000000000..f400c3f72 --- /dev/null +++ b/src/lib/icons.tsx @@ -0,0 +1,529 @@ +import React from 'react' +import {StyleProp, TextStyle, ViewStyle} from 'react-native' +import Svg, {Path, Rect} from 'react-native-svg' + +export function GridIcon({ + style, + solid, +}: { + style?: StyleProp + solid?: boolean +}) { + const DIM = 4 + const ARC = 2 + return ( + + + + + + + ) +} +export function GridIconSolid({style}: {style?: StyleProp}) { + return +} + +export function HomeIcon({ + style, + size, + strokeWidth = 4, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +export function HomeIconSolid({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function MagnifyingGlassIcon({ + style, + size, + strokeWidth = 2, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +// https://github.com/Remix-Design/RemixIcon/blob/master/License +export function BellIcon({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + + ) +} + +// https://github.com/Remix-Design/RemixIcon/blob/master/License +export function BellIconSolid({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + + ) +} + +export function CogIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth: number +}) { + return ( + + + + + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function UserIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function UserGroupIcon({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +export function RepostIcon({ + style, + size = 24, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth: number +}) { + return ( + + + + ) +} + +// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. +export function HeartIcon({ + style, + size = 24, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth: number +}) { + return ( + + + + ) +} + +// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. +export function HeartIconSolid({ + style, + size = 24, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +export function UpIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth: number +}) { + return ( + + + + ) +} + +export function UpIconSolid({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +export function DownIcon({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +export function DownIconSolid({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function CommentBottomArrow({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + let color = 'currentColor' + if ( + style && + typeof style === 'object' && + 'color' in style && + typeof style.color === 'string' + ) { + color = style.color + } + return ( + + + + ) +} + +export function SquareIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +export function RectWideIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +export function RectTallIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} diff --git a/src/lib/images.ts b/src/lib/images.ts index 8d5eaded0..609e03bda 100644 --- a/src/lib/images.ts +++ b/src/lib/images.ts @@ -2,8 +2,8 @@ import RNFetchBlob from 'rn-fetch-blob' import ImageResizer from '@bam.tech/react-native-image-resizer' import {Share} from 'react-native' import RNFS from 'react-native-fs' - -import * as Toast from '../view/com/util/Toast' +import uuid from 'react-native-uuid' +import * as Toast from 'view/com/util/Toast' export interface DownloadAndResizeOpts { uri: string @@ -23,16 +23,12 @@ export interface Image { } export async function downloadAndResize(opts: DownloadAndResizeOpts) { - let appendExt + let appendExt = 'jpeg' try { const urip = new URL(opts.uri) const ext = urip.pathname.split('.').pop() - if (ext === 'jpg' || ext === 'jpeg') { - appendExt = 'jpeg' - } else if (ext === 'png') { + if (ext === 'png') { appendExt = 'png' - } else { - return } } catch (e: any) { console.error('Invalid URI', opts.uri, e) @@ -109,12 +105,18 @@ export async function compressIfNeeded( if (img.size < maxSize) { return img } - return await resize(origUri, { + const resizedImage = await resize(origUri, { width: img.width, height: img.height, mode: 'stretch', maxSize, }) + const finalImageMovedPath = await moveToPremanantPath(resizedImage.path) + const finalImg = { + ...resizedImage, + path: finalImageMovedPath, + } + return finalImg } export interface Dim { @@ -150,3 +152,15 @@ export const saveImageModal = async ({uri}: {uri: string}) => { } RNFS.unlink(imagePath) } + +export const moveToPremanantPath = async (path: string) => { + /* + Since this package stores images in a temp directory, we need to move the file to a permanent location. + Relevant: IOS bug when trying to open a second time: + https://github.com/ivpusic/react-native-image-crop-picker/issues/1199 + */ + const filename = uuid.v4() + const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}` + RNFS.moveFile(path, destinationPath) + return destinationPath +} diff --git a/src/lib/images.web.ts b/src/lib/images.web.ts index 5158e005f..4b6d93af2 100644 --- a/src/lib/images.web.ts +++ b/src/lib/images.web.ts @@ -1,6 +1,5 @@ -import {Share} from 'react-native' - -import * as Toast from '../view/com/util/Toast' +// import {Share} from 'react-native' +// import * as Toast from 'view/com/util/Toast' export interface DownloadAndResizeOpts { uri: string diff --git a/src/lib/link-meta.ts b/src/lib/link-meta.ts deleted file mode 100644 index 2826e969a..000000000 --- a/src/lib/link-meta.ts +++ /dev/null @@ -1,1290 +0,0 @@ -import he from 'he' -import {isBskyAppUrl} from './strings' -import {RootStoreModel} from '../state' -import {extractBskyMeta} from './extractBskyMeta' -import {extractHtmlMeta} from './extractHtmlMeta' - -export enum LikelyType { - HTML, - Text, - Image, - Video, - Audio, - AtpData, - Other, -} - -export interface LinkMeta { - error?: string - likelyType: LikelyType - url: string - title?: string - description?: string - image?: string -} - -export async function getLinkMeta( - store: RootStoreModel, - url: string, - timeout = 5e3, -): Promise { - if (isBskyAppUrl(url)) { - return extractBskyMeta(store, url) - } - - let urlp - try { - urlp = new URL(url) - } catch (e) { - return { - error: 'Invalid URL', - likelyType: LikelyType.Other, - url, - } - } - const likelyType = getLikelyType(urlp) - const meta: LinkMeta = { - likelyType, - url, - } - if (likelyType !== LikelyType.HTML) { - return meta - } - - try { - const controller = new AbortController() - const to = setTimeout(() => controller.abort(), timeout || 5e3) - const httpRes = await fetch(url, { - headers: {accept: 'text/html'}, - signal: controller.signal, - }) - const httpResBody = await httpRes.text() - clearTimeout(to) - const httpResMeta = extractHtmlMeta({ - html: httpResBody, - hostname: urlp?.hostname, - pathname: urlp?.pathname, - }) - meta.title = httpResMeta.title ? he.decode(httpResMeta.title) : undefined - meta.description = httpResMeta.description - ? he.decode(httpResMeta.description) - : undefined - meta.image = httpResMeta.image - } catch (e) { - // failed - console.error(e) - meta.error = 'Failed to fetch link' - } - - return meta -} - -export function getLikelyType(url: URL | string): LikelyType { - if (typeof url === 'string') { - try { - url = new URL(url) - } catch (e) { - return LikelyType.Other - } - } - - const ext = url.pathname.split('.').pop() || '' - if (ext === 'html' || ext === 'htm') { - return LikelyType.HTML - } - const mimeType = EXT_MIME_TYPES[ext] - if (!mimeType) { - return LikelyType.HTML - } - if (mimeType.startsWith('text/')) { - return LikelyType.Text - } - if (mimeType.startsWith('image/')) { - return LikelyType.Image - } - if (mimeType.startsWith('video/')) { - return LikelyType.Video - } - if (mimeType.startsWith('audio/')) { - return LikelyType.Audio - } - return LikelyType.Other -} - -const EXT_MIME_TYPES: Record = { - '123': 'application/vnd.lotus-1-2-3', - '1km': 'application/vnd.1000minds.decision-model+xml', - '3dml': 'text/vnd.in3d.3dml', - '3ds': 'image/x-3ds', - '3g2': 'video/3gpp2', - '3gp': 'video/3gpp', - '3gpp': 'video/3gpp', - '3mf': 'model/3mf', - '7z': 'application/x-7z-compressed', - aab: 'application/x-authorware-bin', - aac: 'audio/x-aac', - aam: 'application/x-authorware-map', - aas: 'application/x-authorware-seg', - abw: 'application/x-abiword', - ac: 'application/vnd.nokia.n-gage.ac+xml', - acc: 'application/vnd.americandynamics.acc', - ace: 'application/x-ace-compressed', - acu: 'application/vnd.acucobol', - acutc: 'application/vnd.acucorp', - adp: 'audio/adpcm', - aep: 'application/vnd.audiograph', - afm: 'application/x-font-type1', - afp: 'application/vnd.ibm.modcap', - age: 'application/vnd.age', - ahead: 'application/vnd.ahead.space', - ai: 'application/postscript', - aif: 'audio/x-aiff', - aifc: 'audio/x-aiff', - aiff: 'audio/x-aiff', - air: 'application/vnd.adobe.air-application-installer-package+zip', - ait: 'application/vnd.dvb.ait', - ami: 'application/vnd.amiga.ami', - amr: 'audio/amr', - apk: 'application/vnd.android.package-archive', - apng: 'image/apng', - appcache: 'text/cache-manifest', - application: 'application/x-ms-application', - apr: 'application/vnd.lotus-approach', - arc: 'application/x-freearc', - arj: 'application/x-arj', - asc: 'application/pgp-signature', - asf: 'video/x-ms-asf', - asm: 'text/x-asm', - aso: 'application/vnd.accpac.simply.aso', - asx: 'video/x-ms-asf', - atc: 'application/vnd.acucorp', - atom: 'application/atom+xml', - atomcat: 'application/atomcat+xml', - atomdeleted: 'application/atomdeleted+xml', - atomsvc: 'application/atomsvc+xml', - atx: 'application/vnd.antix.game-component', - au: 'audio/basic', - avi: 'video/x-msvideo', - avif: 'image/avif', - aw: 'application/applixware', - azf: 'application/vnd.airzip.filesecure.azf', - azs: 'application/vnd.airzip.filesecure.azs', - azv: 'image/vnd.airzip.accelerator.azv', - azw: 'application/vnd.amazon.ebook', - b16: 'image/vnd.pco.b16', - bat: 'application/x-msdownload', - bcpio: 'application/x-bcpio', - bdf: 'application/x-font-bdf', - bdm: 'application/vnd.syncml.dm+wbxml', - bdoc: 'application/x-bdoc', - bed: 'application/vnd.realvnc.bed', - bh2: 'application/vnd.fujitsu.oasysprs', - bin: 'application/octet-stream', - blb: 'application/x-blorb', - blorb: 'application/x-blorb', - bmi: 'application/vnd.bmi', - bmml: 'application/vnd.balsamiq.bmml+xml', - bmp: 'image/x-ms-bmp', - book: 'application/vnd.framemaker', - box: 'application/vnd.previewsystems.box', - boz: 'application/x-bzip2', - bpk: 'application/octet-stream', - bsp: 'model/vnd.valve.source.compiled-map', - btif: 'image/prs.btif', - buffer: 'application/octet-stream', - bz: 'application/x-bzip', - bz2: 'application/x-bzip2', - c: 'text/x-c', - c11amc: 'application/vnd.cluetrust.cartomobile-config', - c11amz: 'application/vnd.cluetrust.cartomobile-config-pkg', - c4d: 'application/vnd.clonk.c4group', - c4f: 'application/vnd.clonk.c4group', - c4g: 'application/vnd.clonk.c4group', - c4p: 'application/vnd.clonk.c4group', - c4u: 'application/vnd.clonk.c4group', - cab: 'application/vnd.ms-cab-compressed', - caf: 'audio/x-caf', - cap: 'application/vnd.tcpdump.pcap', - car: 'application/vnd.curl.car', - cat: 'application/vnd.ms-pki.seccat', - cb7: 'application/x-cbr', - cba: 'application/x-cbr', - cbr: 'application/x-cbr', - cbt: 'application/x-cbr', - cbz: 'application/x-cbr', - cc: 'text/x-c', - cco: 'application/x-cocoa', - cct: 'application/x-director', - ccxml: 'application/ccxml+xml', - cdbcmsg: 'application/vnd.contact.cmsg', - cdf: 'application/x-netcdf', - cdfx: 'application/cdfx+xml', - cdkey: 'application/vnd.mediastation.cdkey', - cdmia: 'application/cdmi-capability', - cdmic: 'application/cdmi-container', - cdmid: 'application/cdmi-domain', - cdmio: 'application/cdmi-object', - cdmiq: 'application/cdmi-queue', - cdx: 'chemical/x-cdx', - cdxml: 'application/vnd.chemdraw+xml', - cdy: 'application/vnd.cinderella', - cer: 'application/pkix-cert', - cfs: 'application/x-cfs-compressed', - cgm: 'image/cgm', - chat: 'application/x-chat', - chm: 'application/vnd.ms-htmlhelp', - chrt: 'application/vnd.kde.kchart', - cif: 'chemical/x-cif', - cii: 'application/vnd.anser-web-certificate-issue-initiation', - cil: 'application/vnd.ms-artgalry', - cjs: 'application/node', - cla: 'application/vnd.claymore', - class: 'application/java-vm', - clkk: 'application/vnd.crick.clicker.keyboard', - clkp: 'application/vnd.crick.clicker.palette', - clkt: 'application/vnd.crick.clicker.template', - clkw: 'application/vnd.crick.clicker.wordbank', - clkx: 'application/vnd.crick.clicker', - clp: 'application/x-msclip', - cmc: 'application/vnd.cosmocaller', - cmdf: 'chemical/x-cmdf', - cml: 'chemical/x-cml', - cmp: 'application/vnd.yellowriver-custom-menu', - cmx: 'image/x-cmx', - cod: 'application/vnd.rim.cod', - coffee: 'text/coffeescript', - com: 'application/x-msdownload', - conf: 'text/plain', - cpio: 'application/x-cpio', - cpp: 'text/x-c', - cpt: 'application/mac-compactpro', - crd: 'application/x-mscardfile', - crl: 'application/pkix-crl', - crt: 'application/x-x509-ca-cert', - crx: 'application/x-chrome-extension', - cryptonote: 'application/vnd.rig.cryptonote', - csh: 'application/x-csh', - csl: 'application/vnd.citationstyles.style+xml', - csml: 'chemical/x-csml', - csp: 'application/vnd.commonspace', - css: 'text/css', - cst: 'application/x-director', - csv: 'text/csv', - cu: 'application/cu-seeme', - curl: 'text/vnd.curl', - cww: 'application/prs.cww', - cxt: 'application/x-director', - cxx: 'text/x-c', - dae: 'model/vnd.collada+xml', - daf: 'application/vnd.mobius.daf', - dart: 'application/vnd.dart', - dataless: 'application/vnd.fdsn.seed', - davmount: 'application/davmount+xml', - dbf: 'application/vnd.dbf', - dbk: 'application/docbook+xml', - dcr: 'application/x-director', - dcurl: 'text/vnd.curl.dcurl', - dd2: 'application/vnd.oma.dd2+xml', - ddd: 'application/vnd.fujixerox.ddd', - ddf: 'application/vnd.syncml.dmddf+xml', - dds: 'image/vnd.ms-dds', - deb: 'application/x-debian-package', - def: 'text/plain', - deploy: 'application/octet-stream', - der: 'application/x-x509-ca-cert', - dfac: 'application/vnd.dreamfactory', - dgc: 'application/x-dgc-compressed', - dic: 'text/x-c', - dir: 'application/x-director', - dis: 'application/vnd.mobius.dis', - 'disposition-notification': 'message/disposition-notification', - dist: 'application/octet-stream', - distz: 'application/octet-stream', - djv: 'image/vnd.djvu', - djvu: 'image/vnd.djvu', - dll: 'application/x-msdownload', - dmg: 'application/x-apple-diskimage', - dmp: 'application/vnd.tcpdump.pcap', - dms: 'application/octet-stream', - dna: 'application/vnd.dna', - doc: 'application/msword', - docm: 'application/vnd.ms-word.document.macroenabled.12', - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - dot: 'application/msword', - dotm: 'application/vnd.ms-word.template.macroenabled.12', - dotx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', - dp: 'application/vnd.osgi.dp', - dpg: 'application/vnd.dpgraph', - dra: 'audio/vnd.dra', - drle: 'image/dicom-rle', - dsc: 'text/prs.lines.tag', - dssc: 'application/dssc+der', - dtb: 'application/x-dtbook+xml', - dtd: 'application/xml-dtd', - dts: 'audio/vnd.dts', - dtshd: 'audio/vnd.dts.hd', - dump: 'application/octet-stream', - dvb: 'video/vnd.dvb.file', - dvi: 'application/x-dvi', - dwd: 'application/atsc-dwd+xml', - dwf: 'model/vnd.dwf', - dwg: 'image/vnd.dwg', - dxf: 'image/vnd.dxf', - dxp: 'application/vnd.spotfire.dxp', - dxr: 'application/x-director', - ear: 'application/java-archive', - ecelp4800: 'audio/vnd.nuera.ecelp4800', - ecelp7470: 'audio/vnd.nuera.ecelp7470', - ecelp9600: 'audio/vnd.nuera.ecelp9600', - ecma: 'application/ecmascript', - edm: 'application/vnd.novadigm.edm', - edx: 'application/vnd.novadigm.edx', - efif: 'application/vnd.picsel', - ei6: 'application/vnd.pg.osasli', - elc: 'application/octet-stream', - emf: 'image/emf', - eml: 'message/rfc822', - emma: 'application/emma+xml', - emotionml: 'application/emotionml+xml', - emz: 'application/x-msmetafile', - eol: 'audio/vnd.digital-winds', - eot: 'application/vnd.ms-fontobject', - eps: 'application/postscript', - epub: 'application/epub+zip', - es: 'application/ecmascript', - es3: 'application/vnd.eszigno3+xml', - esa: 'application/vnd.osgi.subsystem', - esf: 'application/vnd.epson.esf', - et3: 'application/vnd.eszigno3+xml', - etx: 'text/x-setext', - eva: 'application/x-eva', - evy: 'application/x-envoy', - exe: 'application/x-msdownload', - exi: 'application/exi', - exp: 'application/express', - exr: 'image/aces', - ext: 'application/vnd.novadigm.ext', - ez: 'application/andrew-inset', - ez2: 'application/vnd.ezpix-album', - ez3: 'application/vnd.ezpix-package', - f: 'text/x-fortran', - f4v: 'video/x-f4v', - f77: 'text/x-fortran', - f90: 'text/x-fortran', - fbs: 'image/vnd.fastbidsheet', - fcdt: 'application/vnd.adobe.formscentral.fcdt', - fcs: 'application/vnd.isac.fcs', - fdf: 'application/vnd.fdf', - fdt: 'application/fdt+xml', - fe_launch: 'application/vnd.denovo.fcselayout-link', - fg5: 'application/vnd.fujitsu.oasysgp', - fgd: 'application/x-director', - fh: 'image/x-freehand', - fh4: 'image/x-freehand', - fh5: 'image/x-freehand', - fh7: 'image/x-freehand', - fhc: 'image/x-freehand', - fig: 'application/x-xfig', - fits: 'image/fits', - flac: 'audio/x-flac', - fli: 'video/x-fli', - flo: 'application/vnd.micrografx.flo', - flv: 'video/x-flv', - flw: 'application/vnd.kde.kivio', - flx: 'text/vnd.fmi.flexstor', - fly: 'text/vnd.fly', - fm: 'application/vnd.framemaker', - fnc: 'application/vnd.frogans.fnc', - fo: 'application/vnd.software602.filler.form+xml', - for: 'text/x-fortran', - fpx: 'image/vnd.fpx', - frame: 'application/vnd.framemaker', - fsc: 'application/vnd.fsc.weblaunch', - fst: 'image/vnd.fst', - ftc: 'application/vnd.fluxtime.clip', - fti: 'application/vnd.anser-web-funds-transfer-initiation', - fvt: 'video/vnd.fvt', - fxp: 'application/vnd.adobe.fxp', - fxpl: 'application/vnd.adobe.fxp', - fzs: 'application/vnd.fuzzysheet', - g2w: 'application/vnd.geoplan', - g3: 'image/g3fax', - g3w: 'application/vnd.geospace', - gac: 'application/vnd.groove-account', - gam: 'application/x-tads', - gbr: 'application/rpki-ghostbusters', - gca: 'application/x-gca-compressed', - gdl: 'model/vnd.gdl', - gdoc: 'application/vnd.google-apps.document', - ged: 'text/vnd.familysearch.gedcom', - geo: 'application/vnd.dynageo', - geojson: 'application/geo+json', - gex: 'application/vnd.geometry-explorer', - ggb: 'application/vnd.geogebra.file', - ggt: 'application/vnd.geogebra.tool', - ghf: 'application/vnd.groove-help', - gif: 'image/gif', - gim: 'application/vnd.groove-identity-message', - glb: 'model/gltf-binary', - gltf: 'model/gltf+json', - gml: 'application/gml+xml', - gmx: 'application/vnd.gmx', - gnumeric: 'application/x-gnumeric', - gph: 'application/vnd.flographit', - gpx: 'application/gpx+xml', - gqf: 'application/vnd.grafeq', - gqs: 'application/vnd.grafeq', - gram: 'application/srgs', - gramps: 'application/x-gramps-xml', - gre: 'application/vnd.geometry-explorer', - grv: 'application/vnd.groove-injector', - grxml: 'application/srgs+xml', - gsf: 'application/x-font-ghostscript', - gsheet: 'application/vnd.google-apps.spreadsheet', - gslides: 'application/vnd.google-apps.presentation', - gtar: 'application/x-gtar', - gtm: 'application/vnd.groove-tool-message', - gtw: 'model/vnd.gtw', - gv: 'text/vnd.graphviz', - gxf: 'application/gxf', - gxt: 'application/vnd.geonext', - gz: 'application/gzip', - h: 'text/x-c', - h261: 'video/h261', - h263: 'video/h263', - h264: 'video/h264', - hal: 'application/vnd.hal+xml', - hbci: 'application/vnd.hbci', - hbs: 'text/x-handlebars-template', - hdd: 'application/x-virtualbox-hdd', - hdf: 'application/x-hdf', - heic: 'image/heic', - heics: 'image/heic-sequence', - heif: 'image/heif', - heifs: 'image/heif-sequence', - hej2: 'image/hej2k', - held: 'application/atsc-held+xml', - hh: 'text/x-c', - hjson: 'application/hjson', - hlp: 'application/winhlp', - hpgl: 'application/vnd.hp-hpgl', - hpid: 'application/vnd.hp-hpid', - hps: 'application/vnd.hp-hps', - hqx: 'application/mac-binhex40', - hsj2: 'image/hsj2', - htc: 'text/x-component', - htke: 'application/vnd.kenameaapp', - htm: 'text/html', - html: 'text/html', - hvd: 'application/vnd.yamaha.hv-dic', - hvp: 'application/vnd.yamaha.hv-voice', - hvs: 'application/vnd.yamaha.hv-script', - i2g: 'application/vnd.intergeo', - icc: 'application/vnd.iccprofile', - ice: 'x-conference/x-cooltalk', - icm: 'application/vnd.iccprofile', - ico: 'image/x-icon', - ics: 'text/calendar', - ief: 'image/ief', - ifb: 'text/calendar', - ifm: 'application/vnd.shana.informed.formdata', - iges: 'model/iges', - igl: 'application/vnd.igloader', - igm: 'application/vnd.insors.igm', - igs: 'model/iges', - igx: 'application/vnd.micrografx.igx', - iif: 'application/vnd.shana.informed.interchange', - img: 'application/octet-stream', - imp: 'application/vnd.accpac.simply.imp', - ims: 'application/vnd.ms-ims', - in: 'text/plain', - ini: 'text/plain', - ink: 'application/inkml+xml', - inkml: 'application/inkml+xml', - install: 'application/x-install-instructions', - iota: 'application/vnd.astraea-software.iota', - ipfix: 'application/ipfix', - ipk: 'application/vnd.shana.informed.package', - irm: 'application/vnd.ibm.rights-management', - irp: 'application/vnd.irepository.package+xml', - iso: 'application/x-iso9660-image', - itp: 'application/vnd.shana.informed.formtemplate', - its: 'application/its+xml', - ivp: 'application/vnd.immervision-ivp', - ivu: 'application/vnd.immervision-ivu', - jad: 'text/vnd.sun.j2me.app-descriptor', - jade: 'text/jade', - jam: 'application/vnd.jam', - jar: 'application/java-archive', - jardiff: 'application/x-java-archive-diff', - java: 'text/x-java-source', - jhc: 'image/jphc', - jisp: 'application/vnd.jisp', - jls: 'image/jls', - jlt: 'application/vnd.hp-jlyt', - jng: 'image/x-jng', - jnlp: 'application/x-java-jnlp-file', - joda: 'application/vnd.joost.joda-archive', - jp2: 'image/jp2', - jpe: 'image/jpeg', - jpeg: 'image/jpeg', - jpf: 'image/jpx', - jpg: 'image/jpeg', - jpg2: 'image/jp2', - jpgm: 'video/jpm', - jpgv: 'video/jpeg', - jph: 'image/jph', - jpm: 'video/jpm', - jpx: 'image/jpx', - js: 'application/javascript', - json: 'application/json', - json5: 'application/json5', - jsonld: 'application/ld+json', - jsonml: 'application/jsonml+json', - jsx: 'text/jsx', - jxr: 'image/jxr', - jxra: 'image/jxra', - jxrs: 'image/jxrs', - jxs: 'image/jxs', - jxsc: 'image/jxsc', - jxsi: 'image/jxsi', - jxss: 'image/jxss', - kar: 'audio/midi', - karbon: 'application/vnd.kde.karbon', - kdbx: 'application/x-keepass2', - key: 'application/x-iwork-keynote-sffkey', - kfo: 'application/vnd.kde.kformula', - kia: 'application/vnd.kidspiration', - kml: 'application/vnd.google-earth.kml+xml', - kmz: 'application/vnd.google-earth.kmz', - kne: 'application/vnd.kinar', - knp: 'application/vnd.kinar', - kon: 'application/vnd.kde.kontour', - kpr: 'application/vnd.kde.kpresenter', - kpt: 'application/vnd.kde.kpresenter', - kpxx: 'application/vnd.ds-keypoint', - ksp: 'application/vnd.kde.kspread', - ktr: 'application/vnd.kahootz', - ktx: 'image/ktx', - ktx2: 'image/ktx2', - ktz: 'application/vnd.kahootz', - kwd: 'application/vnd.kde.kword', - kwt: 'application/vnd.kde.kword', - lasxml: 'application/vnd.las.las+xml', - latex: 'application/x-latex', - lbd: 'application/vnd.llamagraphics.life-balance.desktop', - lbe: 'application/vnd.llamagraphics.life-balance.exchange+xml', - les: 'application/vnd.hhe.lesson-player', - less: 'text/less', - lgr: 'application/lgr+xml', - lha: 'application/x-lzh-compressed', - link66: 'application/vnd.route66.link66+xml', - list: 'text/plain', - list3820: 'application/vnd.ibm.modcap', - listafp: 'application/vnd.ibm.modcap', - litcoffee: 'text/coffeescript', - lnk: 'application/x-ms-shortcut', - log: 'text/plain', - lostxml: 'application/lost+xml', - lrf: 'application/octet-stream', - lrm: 'application/vnd.ms-lrm', - ltf: 'application/vnd.frogans.ltf', - lua: 'text/x-lua', - luac: 'application/x-lua-bytecode', - lvp: 'audio/vnd.lucent.voice', - lwp: 'application/vnd.lotus-wordpro', - lzh: 'application/x-lzh-compressed', - m13: 'application/x-msmediaview', - m14: 'application/x-msmediaview', - m1v: 'video/mpeg', - m21: 'application/mp21', - m2a: 'audio/mpeg', - m2v: 'video/mpeg', - m3a: 'audio/mpeg', - m3u: 'audio/x-mpegurl', - m3u8: 'application/vnd.apple.mpegurl', - m4a: 'audio/x-m4a', - m4p: 'application/mp4', - m4s: 'video/iso.segment', - m4u: 'video/vnd.mpegurl', - m4v: 'video/x-m4v', - ma: 'application/mathematica', - mads: 'application/mads+xml', - maei: 'application/mmt-aei+xml', - mag: 'application/vnd.ecowin.chart', - maker: 'application/vnd.framemaker', - man: 'text/troff', - manifest: 'text/cache-manifest', - map: 'application/json', - mar: 'application/octet-stream', - markdown: 'text/markdown', - mathml: 'application/mathml+xml', - mb: 'application/mathematica', - mbk: 'application/vnd.mobius.mbk', - mbox: 'application/mbox', - mc1: 'application/vnd.medcalcdata', - mcd: 'application/vnd.mcd', - mcurl: 'text/vnd.curl.mcurl', - md: 'text/markdown', - mdb: 'application/x-msaccess', - mdi: 'image/vnd.ms-modi', - mdx: 'text/mdx', - me: 'text/troff', - mesh: 'model/mesh', - meta4: 'application/metalink4+xml', - metalink: 'application/metalink+xml', - mets: 'application/mets+xml', - mfm: 'application/vnd.mfmp', - mft: 'application/rpki-manifest', - mgp: 'application/vnd.osgeo.mapguide.package', - mgz: 'application/vnd.proteus.magazine', - mid: 'audio/midi', - midi: 'audio/midi', - mie: 'application/x-mie', - mif: 'application/vnd.mif', - mime: 'message/rfc822', - mj2: 'video/mj2', - mjp2: 'video/mj2', - mjs: 'application/javascript', - mk3d: 'video/x-matroska', - mka: 'audio/x-matroska', - mkd: 'text/x-markdown', - mks: 'video/x-matroska', - mkv: 'video/x-matroska', - mlp: 'application/vnd.dolby.mlp', - mmd: 'application/vnd.chipnuts.karaoke-mmd', - mmf: 'application/vnd.smaf', - mml: 'text/mathml', - mmr: 'image/vnd.fujixerox.edmics-mmr', - mng: 'video/x-mng', - mny: 'application/x-msmoney', - mobi: 'application/x-mobipocket-ebook', - mods: 'application/mods+xml', - mov: 'video/quicktime', - movie: 'video/x-sgi-movie', - mp2: 'audio/mpeg', - mp21: 'application/mp21', - mp2a: 'audio/mpeg', - mp3: 'audio/mpeg', - mp4: 'video/mp4', - mp4a: 'audio/mp4', - mp4s: 'application/mp4', - mp4v: 'video/mp4', - mpc: 'application/vnd.mophun.certificate', - mpd: 'application/dash+xml', - mpe: 'video/mpeg', - mpeg: 'video/mpeg', - mpg: 'video/mpeg', - mpg4: 'video/mp4', - mpga: 'audio/mpeg', - mpkg: 'application/vnd.apple.installer+xml', - mpm: 'application/vnd.blueice.multipass', - mpn: 'application/vnd.mophun.application', - mpp: 'application/vnd.ms-project', - mpt: 'application/vnd.ms-project', - mpy: 'application/vnd.ibm.minipay', - mqy: 'application/vnd.mobius.mqy', - mrc: 'application/marc', - mrcx: 'application/marcxml+xml', - ms: 'text/troff', - mscml: 'application/mediaservercontrol+xml', - mseed: 'application/vnd.fdsn.mseed', - mseq: 'application/vnd.mseq', - msf: 'application/vnd.epson.msf', - msg: 'application/vnd.ms-outlook', - msh: 'model/mesh', - msi: 'application/x-msdownload', - msl: 'application/vnd.mobius.msl', - msm: 'application/octet-stream', - msp: 'application/octet-stream', - msty: 'application/vnd.muvee.style', - mtl: 'model/mtl', - mts: 'model/vnd.mts', - mus: 'application/vnd.musician', - musd: 'application/mmt-usd+xml', - musicxml: 'application/vnd.recordare.musicxml+xml', - mvb: 'application/x-msmediaview', - mvt: 'application/vnd.mapbox-vector-tile', - mwf: 'application/vnd.mfer', - mxf: 'application/mxf', - mxl: 'application/vnd.recordare.musicxml', - mxmf: 'audio/mobile-xmf', - mxml: 'application/xv+xml', - mxs: 'application/vnd.triscape.mxs', - mxu: 'video/vnd.mpegurl', - 'n-gage': 'application/vnd.nokia.n-gage.symbian.install', - n3: 'text/n3', - nb: 'application/mathematica', - nbp: 'application/vnd.wolfram.player', - nc: 'application/x-netcdf', - ncx: 'application/x-dtbncx+xml', - nfo: 'text/x-nfo', - ngdat: 'application/vnd.nokia.n-gage.data', - nitf: 'application/vnd.nitf', - nlu: 'application/vnd.neurolanguage.nlu', - nml: 'application/vnd.enliven', - nnd: 'application/vnd.noblenet-directory', - nns: 'application/vnd.noblenet-sealer', - nnw: 'application/vnd.noblenet-web', - npx: 'image/vnd.net-fpx', - nq: 'application/n-quads', - nsc: 'application/x-conference', - nsf: 'application/vnd.lotus-notes', - nt: 'application/n-triples', - ntf: 'application/vnd.nitf', - numbers: 'application/x-iwork-numbers-sffnumbers', - nzb: 'application/x-nzb', - oa2: 'application/vnd.fujitsu.oasys2', - oa3: 'application/vnd.fujitsu.oasys3', - oas: 'application/vnd.fujitsu.oasys', - obd: 'application/x-msbinder', - obgx: 'application/vnd.openblox.game+xml', - obj: 'model/obj', - oda: 'application/oda', - odb: 'application/vnd.oasis.opendocument.database', - odc: 'application/vnd.oasis.opendocument.chart', - odf: 'application/vnd.oasis.opendocument.formula', - odft: 'application/vnd.oasis.opendocument.formula-template', - odg: 'application/vnd.oasis.opendocument.graphics', - odi: 'application/vnd.oasis.opendocument.image', - odm: 'application/vnd.oasis.opendocument.text-master', - odp: 'application/vnd.oasis.opendocument.presentation', - ods: 'application/vnd.oasis.opendocument.spreadsheet', - odt: 'application/vnd.oasis.opendocument.text', - oga: 'audio/ogg', - ogex: 'model/vnd.opengex', - ogg: 'audio/ogg', - ogv: 'video/ogg', - ogx: 'application/ogg', - omdoc: 'application/omdoc+xml', - onepkg: 'application/onenote', - onetmp: 'application/onenote', - onetoc: 'application/onenote', - onetoc2: 'application/onenote', - opf: 'application/oebps-package+xml', - opml: 'text/x-opml', - oprc: 'application/vnd.palm', - opus: 'audio/ogg', - org: 'text/x-org', - osf: 'application/vnd.yamaha.openscoreformat', - osfpvg: 'application/vnd.yamaha.openscoreformat.osfpvg+xml', - osm: 'application/vnd.openstreetmap.data+xml', - otc: 'application/vnd.oasis.opendocument.chart-template', - otf: 'font/otf', - otg: 'application/vnd.oasis.opendocument.graphics-template', - oth: 'application/vnd.oasis.opendocument.text-web', - oti: 'application/vnd.oasis.opendocument.image-template', - otp: 'application/vnd.oasis.opendocument.presentation-template', - ots: 'application/vnd.oasis.opendocument.spreadsheet-template', - ott: 'application/vnd.oasis.opendocument.text-template', - ova: 'application/x-virtualbox-ova', - ovf: 'application/x-virtualbox-ovf', - owl: 'application/rdf+xml', - oxps: 'application/oxps', - oxt: 'application/vnd.openofficeorg.extension', - p: 'text/x-pascal', - p10: 'application/pkcs10', - p12: 'application/x-pkcs12', - p7b: 'application/x-pkcs7-certificates', - p7c: 'application/pkcs7-mime', - p7m: 'application/pkcs7-mime', - p7r: 'application/x-pkcs7-certreqresp', - p7s: 'application/pkcs7-signature', - p8: 'application/pkcs8', - pac: 'application/x-ns-proxy-autoconfig', - pages: 'application/x-iwork-pages-sffpages', - pas: 'text/x-pascal', - paw: 'application/vnd.pawaafile', - pbd: 'application/vnd.powerbuilder6', - pbm: 'image/x-portable-bitmap', - pcap: 'application/vnd.tcpdump.pcap', - pcf: 'application/x-font-pcf', - pcl: 'application/vnd.hp-pcl', - pclxl: 'application/vnd.hp-pclxl', - pct: 'image/x-pict', - pcurl: 'application/vnd.curl.pcurl', - pcx: 'image/x-pcx', - pdb: 'application/x-pilot', - pde: 'text/x-processing', - pdf: 'application/pdf', - pem: 'application/x-x509-ca-cert', - pfa: 'application/x-font-type1', - pfb: 'application/x-font-type1', - pfm: 'application/x-font-type1', - pfr: 'application/font-tdpfr', - pfx: 'application/x-pkcs12', - pgm: 'image/x-portable-graymap', - pgn: 'application/x-chess-pgn', - pgp: 'application/pgp-encrypted', - php: 'application/x-httpd-php', - pic: 'image/x-pict', - pkg: 'application/octet-stream', - pki: 'application/pkixcmp', - pkipath: 'application/pkix-pkipath', - pkpass: 'application/vnd.apple.pkpass', - pl: 'application/x-perl', - plb: 'application/vnd.3gpp.pic-bw-large', - plc: 'application/vnd.mobius.plc', - plf: 'application/vnd.pocketlearn', - pls: 'application/pls+xml', - pm: 'application/x-perl', - pml: 'application/vnd.ctc-posml', - png: 'image/png', - pnm: 'image/x-portable-anymap', - portpkg: 'application/vnd.macports.portpkg', - pot: 'application/vnd.ms-powerpoint', - potm: 'application/vnd.ms-powerpoint.template.macroenabled.12', - potx: 'application/vnd.openxmlformats-officedocument.presentationml.template', - ppam: 'application/vnd.ms-powerpoint.addin.macroenabled.12', - ppd: 'application/vnd.cups-ppd', - ppm: 'image/x-portable-pixmap', - pps: 'application/vnd.ms-powerpoint', - ppsm: 'application/vnd.ms-powerpoint.slideshow.macroenabled.12', - ppsx: 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', - ppt: 'application/vnd.ms-powerpoint', - pptm: 'application/vnd.ms-powerpoint.presentation.macroenabled.12', - pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - pqa: 'application/vnd.palm', - prc: 'application/x-pilot', - pre: 'application/vnd.lotus-freelance', - prf: 'application/pics-rules', - provx: 'application/provenance+xml', - ps: 'application/postscript', - psb: 'application/vnd.3gpp.pic-bw-small', - psd: 'image/vnd.adobe.photoshop', - psf: 'application/x-font-linux-psf', - pskcxml: 'application/pskc+xml', - pti: 'image/prs.pti', - ptid: 'application/vnd.pvi.ptid1', - pub: 'application/x-mspublisher', - pvb: 'application/vnd.3gpp.pic-bw-var', - pwn: 'application/vnd.3m.post-it-notes', - pya: 'audio/vnd.ms-playready.media.pya', - pyv: 'video/vnd.ms-playready.media.pyv', - qam: 'application/vnd.epson.quickanime', - qbo: 'application/vnd.intu.qbo', - qfx: 'application/vnd.intu.qfx', - qps: 'application/vnd.publishare-delta-tree', - qt: 'video/quicktime', - qwd: 'application/vnd.quark.quarkxpress', - qwt: 'application/vnd.quark.quarkxpress', - qxb: 'application/vnd.quark.quarkxpress', - qxd: 'application/vnd.quark.quarkxpress', - qxl: 'application/vnd.quark.quarkxpress', - qxt: 'application/vnd.quark.quarkxpress', - ra: 'audio/x-realaudio', - ram: 'audio/x-pn-realaudio', - raml: 'application/raml+yaml', - rapd: 'application/route-apd+xml', - rar: 'application/x-rar-compressed', - ras: 'image/x-cmu-raster', - rcprofile: 'application/vnd.ipunplugged.rcprofile', - rdf: 'application/rdf+xml', - rdz: 'application/vnd.data-vision.rdz', - relo: 'application/p2p-overlay+xml', - rep: 'application/vnd.businessobjects', - res: 'application/x-dtbresource+xml', - rgb: 'image/x-rgb', - rif: 'application/reginfo+xml', - rip: 'audio/vnd.rip', - ris: 'application/x-research-info-systems', - rl: 'application/resource-lists+xml', - rlc: 'image/vnd.fujixerox.edmics-rlc', - rld: 'application/resource-lists-diff+xml', - rm: 'application/vnd.rn-realmedia', - rmi: 'audio/midi', - rmp: 'audio/x-pn-realaudio-plugin', - rms: 'application/vnd.jcp.javame.midlet-rms', - rmvb: 'application/vnd.rn-realmedia-vbr', - rnc: 'application/relax-ng-compact-syntax', - rng: 'application/xml', - roa: 'application/rpki-roa', - roff: 'text/troff', - rp9: 'application/vnd.cloanto.rp9', - rpm: 'application/x-redhat-package-manager', - rpss: 'application/vnd.nokia.radio-presets', - rpst: 'application/vnd.nokia.radio-preset', - rq: 'application/sparql-query', - rs: 'application/rls-services+xml', - rsat: 'application/atsc-rsat+xml', - rsd: 'application/rsd+xml', - rsheet: 'application/urc-ressheet+xml', - rss: 'application/rss+xml', - rtf: 'text/rtf', - rtx: 'text/richtext', - run: 'application/x-makeself', - rusd: 'application/route-usd+xml', - s: 'text/x-asm', - s3m: 'audio/s3m', - saf: 'application/vnd.yamaha.smaf-audio', - sass: 'text/x-sass', - sbml: 'application/sbml+xml', - sc: 'application/vnd.ibm.secure-container', - scd: 'application/x-msschedule', - scm: 'application/vnd.lotus-screencam', - scq: 'application/scvp-cv-request', - scs: 'application/scvp-cv-response', - scss: 'text/x-scss', - scurl: 'text/vnd.curl.scurl', - sda: 'application/vnd.stardivision.draw', - sdc: 'application/vnd.stardivision.calc', - sdd: 'application/vnd.stardivision.impress', - sdkd: 'application/vnd.solent.sdkm+xml', - sdkm: 'application/vnd.solent.sdkm+xml', - sdp: 'application/sdp', - sdw: 'application/vnd.stardivision.writer', - sea: 'application/x-sea', - see: 'application/vnd.seemail', - seed: 'application/vnd.fdsn.seed', - sema: 'application/vnd.sema', - semd: 'application/vnd.semd', - semf: 'application/vnd.semf', - senmlx: 'application/senml+xml', - sensmlx: 'application/sensml+xml', - ser: 'application/java-serialized-object', - setpay: 'application/set-payment-initiation', - setreg: 'application/set-registration-initiation', - 'sfd-hdstx': 'application/vnd.hydrostatix.sof-data', - sfs: 'application/vnd.spotfire.sfs', - sfv: 'text/x-sfv', - sgi: 'image/sgi', - sgl: 'application/vnd.stardivision.writer-global', - sgm: 'text/sgml', - sgml: 'text/sgml', - sh: 'application/x-sh', - shar: 'application/x-shar', - shex: 'text/shex', - shf: 'application/shf+xml', - shtml: 'text/html', - sid: 'image/x-mrsid-image', - sieve: 'application/sieve', - sig: 'application/pgp-signature', - sil: 'audio/silk', - silo: 'model/mesh', - sis: 'application/vnd.symbian.install', - sisx: 'application/vnd.symbian.install', - sit: 'application/x-stuffit', - sitx: 'application/x-stuffitx', - siv: 'application/sieve', - skd: 'application/vnd.koan', - skm: 'application/vnd.koan', - skp: 'application/vnd.koan', - skt: 'application/vnd.koan', - sldm: 'application/vnd.ms-powerpoint.slide.macroenabled.12', - sldx: 'application/vnd.openxmlformats-officedocument.presentationml.slide', - slim: 'text/slim', - slm: 'text/slim', - sls: 'application/route-s-tsid+xml', - slt: 'application/vnd.epson.salt', - sm: 'application/vnd.stepmania.stepchart', - smf: 'application/vnd.stardivision.math', - smi: 'application/smil+xml', - smil: 'application/smil+xml', - smv: 'video/x-smv', - smzip: 'application/vnd.stepmania.package', - snd: 'audio/basic', - snf: 'application/x-font-snf', - so: 'application/octet-stream', - spc: 'application/x-pkcs7-certificates', - spdx: 'text/spdx', - spf: 'application/vnd.yamaha.smaf-phrase', - spl: 'application/x-futuresplash', - spot: 'text/vnd.in3d.spot', - spp: 'application/scvp-vp-response', - spq: 'application/scvp-vp-request', - spx: 'audio/ogg', - sql: 'application/x-sql', - src: 'application/x-wais-source', - srt: 'application/x-subrip', - sru: 'application/sru+xml', - srx: 'application/sparql-results+xml', - ssdl: 'application/ssdl+xml', - sse: 'application/vnd.kodak-descriptor', - ssf: 'application/vnd.epson.ssf', - ssml: 'application/ssml+xml', - st: 'application/vnd.sailingtracker.track', - stc: 'application/vnd.sun.xml.calc.template', - std: 'application/vnd.sun.xml.draw.template', - stf: 'application/vnd.wt.stf', - sti: 'application/vnd.sun.xml.impress.template', - stk: 'application/hyperstudio', - stl: 'model/stl', - stpx: 'model/step+xml', - stpxz: 'model/step-xml+zip', - stpz: 'model/step+zip', - str: 'application/vnd.pg.format', - stw: 'application/vnd.sun.xml.writer.template', - styl: 'text/stylus', - stylus: 'text/stylus', - sub: 'text/vnd.dvb.subtitle', - sus: 'application/vnd.sus-calendar', - susp: 'application/vnd.sus-calendar', - sv4cpio: 'application/x-sv4cpio', - sv4crc: 'application/x-sv4crc', - svc: 'application/vnd.dvb.service', - svd: 'application/vnd.svd', - svg: 'image/svg+xml', - svgz: 'image/svg+xml', - swa: 'application/x-director', - swf: 'application/x-shockwave-flash', - swi: 'application/vnd.aristanetworks.swi', - swidtag: 'application/swid+xml', - sxc: 'application/vnd.sun.xml.calc', - sxd: 'application/vnd.sun.xml.draw', - sxg: 'application/vnd.sun.xml.writer.global', - sxi: 'application/vnd.sun.xml.impress', - sxm: 'application/vnd.sun.xml.math', - sxw: 'application/vnd.sun.xml.writer', - t: 'text/troff', - t3: 'application/x-t3vm-image', - t38: 'image/t38', - taglet: 'application/vnd.mynfc', - tao: 'application/vnd.tao.intent-module-archive', - tap: 'image/vnd.tencent.tap', - tar: 'application/x-tar', - tcap: 'application/vnd.3gpp2.tcap', - tcl: 'application/x-tcl', - td: 'application/urc-targetdesc+xml', - teacher: 'application/vnd.smart.teacher', - tei: 'application/tei+xml', - teicorpus: 'application/tei+xml', - tex: 'application/x-tex', - texi: 'application/x-texinfo', - texinfo: 'application/x-texinfo', - text: 'text/plain', - tfi: 'application/thraud+xml', - tfm: 'application/x-tex-tfm', - tfx: 'image/tiff-fx', - tga: 'image/x-tga', - thmx: 'application/vnd.ms-officetheme', - tif: 'image/tiff', - tiff: 'image/tiff', - tk: 'application/x-tcl', - tmo: 'application/vnd.tmobile-livetv', - toml: 'application/toml', - torrent: 'application/x-bittorrent', - tpl: 'application/vnd.groove-tool-template', - tpt: 'application/vnd.trid.tpt', - tr: 'text/troff', - tra: 'application/vnd.trueapp', - trig: 'application/trig', - trm: 'application/x-msterminal', - ts: 'video/mp2t', - tsd: 'application/timestamped-data', - tsv: 'text/tab-separated-values', - ttc: 'font/collection', - ttf: 'font/ttf', - ttl: 'text/turtle', - ttml: 'application/ttml+xml', - twd: 'application/vnd.simtech-mindmapper', - twds: 'application/vnd.simtech-mindmapper', - txd: 'application/vnd.genomatix.tuxedo', - txf: 'application/vnd.mobius.txf', - txt: 'text/plain', - u32: 'application/x-authorware-bin', - u8dsn: 'message/global-delivery-status', - u8hdr: 'message/global-headers', - u8mdn: 'message/global-disposition-notification', - u8msg: 'message/global', - ubj: 'application/ubjson', - udeb: 'application/x-debian-package', - ufd: 'application/vnd.ufdl', - ufdl: 'application/vnd.ufdl', - ulx: 'application/x-glulx', - umj: 'application/vnd.umajin', - unityweb: 'application/vnd.unity', - uoml: 'application/vnd.uoml+xml', - uri: 'text/uri-list', - uris: 'text/uri-list', - urls: 'text/uri-list', - usdz: 'model/vnd.usdz+zip', - ustar: 'application/x-ustar', - utz: 'application/vnd.uiq.theme', - uu: 'text/x-uuencode', - uva: 'audio/vnd.dece.audio', - uvd: 'application/vnd.dece.data', - uvf: 'application/vnd.dece.data', - uvg: 'image/vnd.dece.graphic', - uvh: 'video/vnd.dece.hd', - uvi: 'image/vnd.dece.graphic', - uvm: 'video/vnd.dece.mobile', - uvp: 'video/vnd.dece.pd', - uvs: 'video/vnd.dece.sd', - uvt: 'application/vnd.dece.ttml+xml', - uvu: 'video/vnd.uvvu.mp4', - uvv: 'video/vnd.dece.video', - uvva: 'audio/vnd.dece.audio', - uvvd: 'application/vnd.dece.data', - uvvf: 'application/vnd.dece.data', - uvvg: 'image/vnd.dece.graphic', - uvvh: 'video/vnd.dece.hd', - uvvi: 'image/vnd.dece.graphic', - uvvm: 'video/vnd.dece.mobile', - uvvp: 'video/vnd.dece.pd', - uvvs: 'video/vnd.dece.sd', - uvvt: 'application/vnd.dece.ttml+xml', - uvvu: 'video/vnd.uvvu.mp4', - uvvv: 'video/vnd.dece.video', - uvvx: 'application/vnd.dece.unspecified', - uvvz: 'application/vnd.dece.zip', - uvx: 'application/vnd.dece.unspecified', - uvz: 'application/vnd.dece.zip', - vbox: 'application/x-virtualbox-vbox', - 'vbox-extpack': 'application/x-virtualbox-vbox-extpack', - vcard: 'text/vcard', - vcd: 'application/x-cdlink', - vcf: 'text/x-vcard', - vcg: 'application/vnd.groove-vcard', - vcs: 'text/x-vcalendar', - vcx: 'application/vnd.vcx', - vdi: 'application/x-virtualbox-vdi', - vds: 'model/vnd.sap.vds', - vhd: 'application/x-virtualbox-vhd', - vis: 'application/vnd.visionary', - viv: 'video/vnd.vivo', - vmdk: 'application/x-virtualbox-vmdk', - vob: 'video/x-ms-vob', - vor: 'application/vnd.stardivision.writer', - vox: 'application/x-authorware-bin', - vrml: 'model/vrml', - vsd: 'application/vnd.visio', - vsf: 'application/vnd.vsf', - vss: 'application/vnd.visio', - vst: 'application/vnd.visio', - vsw: 'application/vnd.visio', - vtf: 'image/vnd.valve.source.texture', - vtt: 'text/vtt', - vtu: 'model/vnd.vtu', - vxml: 'application/voicexml+xml', - w3d: 'application/x-director', - wad: 'application/x-doom', - wadl: 'application/vnd.sun.wadl+xml', - war: 'application/java-archive', - wasm: 'application/wasm', - wav: 'audio/x-wav', - wax: 'audio/x-ms-wax', - wbmp: 'image/vnd.wap.wbmp', - wbs: 'application/vnd.criticaltools.wbs+xml', - wbxml: 'application/vnd.wap.wbxml', - wcm: 'application/vnd.ms-works', - wdb: 'application/vnd.ms-works', - wdp: 'image/vnd.ms-photo', - weba: 'audio/webm', - webapp: 'application/x-web-app-manifest+json', - webm: 'video/webm', - webmanifest: 'application/manifest+json', - webp: 'image/webp', - wg: 'application/vnd.pmi.widget', - wgt: 'application/widget', - wks: 'application/vnd.ms-works', - wm: 'video/x-ms-wm', - wma: 'audio/x-ms-wma', - wmd: 'application/x-ms-wmd', - wmf: 'image/wmf', - wml: 'text/vnd.wap.wml', - wmlc: 'application/vnd.wap.wmlc', - wmls: 'text/vnd.wap.wmlscript', - wmlsc: 'application/vnd.wap.wmlscriptc', - wmv: 'video/x-ms-wmv', - wmx: 'video/x-ms-wmx', - wmz: 'application/x-msmetafile', - woff: 'font/woff', - woff2: 'font/woff2', - wpd: 'application/vnd.wordperfect', - wpl: 'application/vnd.ms-wpl', - wps: 'application/vnd.ms-works', - wqd: 'application/vnd.wqd', - wri: 'application/x-mswrite', - wrl: 'model/vrml', - wsc: 'message/vnd.wfa.wsc', - wsdl: 'application/wsdl+xml', - wspolicy: 'application/wspolicy+xml', - wtb: 'application/vnd.webturbo', - wvx: 'video/x-ms-wvx', - x32: 'application/x-authorware-bin', - x3d: 'model/x3d+xml', - x3db: 'model/x3d+fastinfoset', - x3dbz: 'model/x3d+binary', - x3dv: 'model/x3d-vrml', - x3dvz: 'model/x3d+vrml', - x3dz: 'model/x3d+xml', - x_b: 'model/vnd.parasolid.transmit.binary', - x_t: 'model/vnd.parasolid.transmit.text', - xaml: 'application/xaml+xml', - xap: 'application/x-silverlight-app', - xar: 'application/vnd.xara', - xav: 'application/xcap-att+xml', - xbap: 'application/x-ms-xbap', - xbd: 'application/vnd.fujixerox.docuworks.binder', - xbm: 'image/x-xbitmap', - xca: 'application/xcap-caps+xml', - xcs: 'application/calendar+xml', - xdf: 'application/xcap-diff+xml', - xdm: 'application/vnd.syncml.dm+xml', - xdp: 'application/vnd.adobe.xdp+xml', - xdssc: 'application/dssc+xml', - xdw: 'application/vnd.fujixerox.docuworks', - xel: 'application/xcap-el+xml', - xenc: 'application/xenc+xml', - xer: 'application/patch-ops-error+xml', - xfdf: 'application/vnd.adobe.xfdf', - xfdl: 'application/vnd.xfdl', - xht: 'application/xhtml+xml', - xhtml: 'application/xhtml+xml', - xhvml: 'application/xv+xml', - xif: 'image/vnd.xiff', - xla: 'application/vnd.ms-excel', - xlam: 'application/vnd.ms-excel.addin.macroenabled.12', - xlc: 'application/vnd.ms-excel', - xlf: 'application/xliff+xml', - xlm: 'application/vnd.ms-excel', - xls: 'application/vnd.ms-excel', - xlsb: 'application/vnd.ms-excel.sheet.binary.macroenabled.12', - xlsm: 'application/vnd.ms-excel.sheet.macroenabled.12', - xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - xlt: 'application/vnd.ms-excel', - xltm: 'application/vnd.ms-excel.template.macroenabled.12', - xltx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', - xlw: 'application/vnd.ms-excel', - xm: 'audio/xm', - xml: 'text/xml', - xns: 'application/xcap-ns+xml', - xo: 'application/vnd.olpc-sugar', - xop: 'application/xop+xml', - xpi: 'application/x-xpinstall', - xpl: 'application/xproc+xml', - xpm: 'image/x-xpixmap', - xpr: 'application/vnd.is-xpr', - xps: 'application/vnd.ms-xpsdocument', - xpw: 'application/vnd.intercon.formnet', - xpx: 'application/vnd.intercon.formnet', - xsd: 'application/xml', - xsl: 'application/xslt+xml', - xslt: 'application/xslt+xml', - xsm: 'application/vnd.syncml+xml', - xspf: 'application/xspf+xml', - xul: 'application/vnd.mozilla.xul+xml', - xvm: 'application/xv+xml', - xvml: 'application/xv+xml', - xwd: 'image/x-xwindowdump', - xyz: 'chemical/x-xyz', - xz: 'application/x-xz', - yaml: 'text/yaml', - yang: 'application/yang', - yin: 'application/yin+xml', - yml: 'text/yaml', - ymp: 'text/x-suse-ymp', - z1: 'application/x-zmachine', - z2: 'application/x-zmachine', - z3: 'application/x-zmachine', - z4: 'application/x-zmachine', - z5: 'application/x-zmachine', - z6: 'application/x-zmachine', - z7: 'application/x-zmachine', - z8: 'application/x-zmachine', - zaz: 'application/vnd.zzazz.deck+xml', - zip: 'application/zip', - zir: 'application/vnd.zul', - zirz: 'application/vnd.zul', - zmm: 'application/vnd.handheld-entertainment+xml', -} diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts new file mode 100644 index 000000000..fba41260d --- /dev/null +++ b/src/lib/link-meta/bsky.ts @@ -0,0 +1,99 @@ +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 {PostThreadViewModel} from 'state/models/post-thread-view' + +import {Home} from 'view/screens/Home' +import {Search} from 'view/screens/Search' +import {Notifications} from 'view/screens/Notifications' +import {PostThread} from 'view/screens/PostThread' +import {PostUpvotedBy} from 'view/screens/PostUpvotedBy' +import {PostRepostedBy} from 'view/screens/PostRepostedBy' +import {Profile} from 'view/screens/Profile' +import {ProfileFollowers} from 'view/screens/ProfileFollowers' +import {ProfileFollows} from 'view/screens/ProfileFollows' + +// NOTE +// this is a hack around the lack of hosted social metadata +// remove once that's implemented +// -prf +export async function extractBskyMeta( + store: RootStoreModel, + url: string, +): Promise { + url = convertBskyAppUrlIfNeeded(url) + const route = matchRoute(url) + let meta: LinkMeta = { + likelyType: LikelyType.AtpData, + url, + title: route.defaultTitle, + } + + if (route.Com === Home) { + meta = { + ...meta, + title: 'Bluesky', + description: 'A new kind of social network', + } + } else if (route.Com === Search) { + meta = { + ...meta, + title: 'Search - Bluesky', + description: 'A new kind of social network', + } + } else if (route.Com === Notifications) { + meta = { + ...meta, + title: 'Notifications - Bluesky', + description: 'A new kind of social network', + } + } else if ( + route.Com === PostThread || + route.Com === PostUpvotedBy || + route.Com === PostRepostedBy + ) { + // post and post-related screens + const threadUri = makeRecordUri( + route.params.name, + 'app.bsky.feed.post', + route.params.rkey, + ) + const threadView = new PostThreadViewModel(store, { + uri: threadUri, + depth: 0, + }) + await threadView.setup().catch(_err => undefined) + const title = [ + route.Com === PostUpvotedBy + ? 'Likes on a post by' + : route.Com === PostRepostedBy + ? 'Reposts of a post by' + : 'Post by', + threadView.thread?.post.author.displayName || + threadView.thread?.post.author.handle || + 'a bluesky user', + ].join(' ') + meta = { + ...meta, + title, + description: threadView.thread?.postRecord?.text, + } + } else if ( + route.Com === Profile || + route.Com === ProfileFollowers || + route.Com === ProfileFollows + ) { + // profile and profile-related screens + const profile = await store.profiles.getProfile(route.params.name) + if (profile?.data) { + meta = { + ...meta, + title: profile.data.displayName || profile.data.handle, + description: profile.data.description, + } + } + } + + return meta +} diff --git a/src/lib/link-meta/html.ts b/src/lib/link-meta/html.ts new file mode 100644 index 000000000..220f8431d --- /dev/null +++ b/src/lib/link-meta/html.ts @@ -0,0 +1,71 @@ +import {extractTwitterMeta} from './twitter' +import {extractYoutubeMeta} from './youtube' + +interface ExtractHtmlMetaInput { + html: string + hostname?: string + pathname?: string +} + +export const extractHtmlMeta = ({ + html, + hostname, + pathname, +}: ExtractHtmlMetaInput): Record => { + const htmlTitleRegex = /([^<]+)<\/title>/i + + let res: Record = {} + + const match = htmlTitleRegex.exec(html) + + if (match) { + res.title = match[1].trim() + } + + let metaMatch + let propMatch + const metaRe = /]+)>/gis + while ((metaMatch = metaRe.exec(html))) { + let propName + let propValue + const propRe = /(name|property|content)="([^"]+)"/gis + while ((propMatch = propRe.exec(metaMatch[1]))) { + if (propMatch[1] === 'content') { + propValue = propMatch[2] + } else { + propName = propMatch[2] + } + } + if (!propName || !propValue) { + continue + } + switch (propName?.trim()) { + case 'title': + case 'og:title': + case 'twitter:title': + res.title = propValue?.trim() + break + case 'description': + case 'og:description': + case 'twitter:description': + res.description = propValue?.trim() + break + case 'og:image': + case 'twitter:image': + res.image = propValue?.trim() + break + } + } + + const isYoutubeUrl = + hostname?.includes('youtube.') || hostname?.includes('youtu.be') + const isTwitterUrl = hostname?.includes('twitter.') + // Workaround for some websites not having a title or description in the meta tags in the initial serve + if (isYoutubeUrl) { + res = {...res, ...extractYoutubeMeta(html)} + } else if (isTwitterUrl && pathname) { + res = {...extractTwitterMeta({pathname})} + } + + return res +} diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts new file mode 100644 index 000000000..6c4ad5384 --- /dev/null +++ b/src/lib/link-meta/link-meta.ts @@ -0,0 +1,1290 @@ +import he from 'he' +import {isBskyAppUrl} from '../strings/url-helpers' +import {RootStoreModel} from 'state/index' +import {extractBskyMeta} from './bsky' +import {extractHtmlMeta} from './html' + +export enum LikelyType { + HTML, + Text, + Image, + Video, + Audio, + AtpData, + Other, +} + +export interface LinkMeta { + error?: string + likelyType: LikelyType + url: string + title?: string + description?: string + image?: string +} + +export async function getLinkMeta( + store: RootStoreModel, + url: string, + timeout = 5e3, +): Promise { + if (isBskyAppUrl(url)) { + return extractBskyMeta(store, url) + } + + let urlp + try { + urlp = new URL(url) + } catch (e) { + return { + error: 'Invalid URL', + likelyType: LikelyType.Other, + url, + } + } + const likelyType = getLikelyType(urlp) + const meta: LinkMeta = { + likelyType, + url, + } + if (likelyType !== LikelyType.HTML) { + return meta + } + + try { + const controller = new AbortController() + const to = setTimeout(() => controller.abort(), timeout || 5e3) + const httpRes = await fetch(url, { + headers: {accept: 'text/html'}, + signal: controller.signal, + }) + const httpResBody = await httpRes.text() + clearTimeout(to) + const httpResMeta = extractHtmlMeta({ + html: httpResBody, + hostname: urlp?.hostname, + pathname: urlp?.pathname, + }) + meta.title = httpResMeta.title ? he.decode(httpResMeta.title) : undefined + meta.description = httpResMeta.description + ? he.decode(httpResMeta.description) + : undefined + meta.image = httpResMeta.image + } catch (e) { + // failed + console.error(e) + meta.error = 'Failed to fetch link' + } + + return meta +} + +export function getLikelyType(url: URL | string): LikelyType { + if (typeof url === 'string') { + try { + url = new URL(url) + } catch (e) { + return LikelyType.Other + } + } + + const ext = url.pathname.split('.').pop() || '' + if (ext === 'html' || ext === 'htm') { + return LikelyType.HTML + } + const mimeType = EXT_MIME_TYPES[ext] + if (!mimeType) { + return LikelyType.HTML + } + if (mimeType.startsWith('text/')) { + return LikelyType.Text + } + if (mimeType.startsWith('image/')) { + return LikelyType.Image + } + if (mimeType.startsWith('video/')) { + return LikelyType.Video + } + if (mimeType.startsWith('audio/')) { + return LikelyType.Audio + } + return LikelyType.Other +} + +const EXT_MIME_TYPES: Record = { + '123': 'application/vnd.lotus-1-2-3', + '1km': 'application/vnd.1000minds.decision-model+xml', + '3dml': 'text/vnd.in3d.3dml', + '3ds': 'image/x-3ds', + '3g2': 'video/3gpp2', + '3gp': 'video/3gpp', + '3gpp': 'video/3gpp', + '3mf': 'model/3mf', + '7z': 'application/x-7z-compressed', + aab: 'application/x-authorware-bin', + aac: 'audio/x-aac', + aam: 'application/x-authorware-map', + aas: 'application/x-authorware-seg', + abw: 'application/x-abiword', + ac: 'application/vnd.nokia.n-gage.ac+xml', + acc: 'application/vnd.americandynamics.acc', + ace: 'application/x-ace-compressed', + acu: 'application/vnd.acucobol', + acutc: 'application/vnd.acucorp', + adp: 'audio/adpcm', + aep: 'application/vnd.audiograph', + afm: 'application/x-font-type1', + afp: 'application/vnd.ibm.modcap', + age: 'application/vnd.age', + ahead: 'application/vnd.ahead.space', + ai: 'application/postscript', + aif: 'audio/x-aiff', + aifc: 'audio/x-aiff', + aiff: 'audio/x-aiff', + air: 'application/vnd.adobe.air-application-installer-package+zip', + ait: 'application/vnd.dvb.ait', + ami: 'application/vnd.amiga.ami', + amr: 'audio/amr', + apk: 'application/vnd.android.package-archive', + apng: 'image/apng', + appcache: 'text/cache-manifest', + application: 'application/x-ms-application', + apr: 'application/vnd.lotus-approach', + arc: 'application/x-freearc', + arj: 'application/x-arj', + asc: 'application/pgp-signature', + asf: 'video/x-ms-asf', + asm: 'text/x-asm', + aso: 'application/vnd.accpac.simply.aso', + asx: 'video/x-ms-asf', + atc: 'application/vnd.acucorp', + atom: 'application/atom+xml', + atomcat: 'application/atomcat+xml', + atomdeleted: 'application/atomdeleted+xml', + atomsvc: 'application/atomsvc+xml', + atx: 'application/vnd.antix.game-component', + au: 'audio/basic', + avi: 'video/x-msvideo', + avif: 'image/avif', + aw: 'application/applixware', + azf: 'application/vnd.airzip.filesecure.azf', + azs: 'application/vnd.airzip.filesecure.azs', + azv: 'image/vnd.airzip.accelerator.azv', + azw: 'application/vnd.amazon.ebook', + b16: 'image/vnd.pco.b16', + bat: 'application/x-msdownload', + bcpio: 'application/x-bcpio', + bdf: 'application/x-font-bdf', + bdm: 'application/vnd.syncml.dm+wbxml', + bdoc: 'application/x-bdoc', + bed: 'application/vnd.realvnc.bed', + bh2: 'application/vnd.fujitsu.oasysprs', + bin: 'application/octet-stream', + blb: 'application/x-blorb', + blorb: 'application/x-blorb', + bmi: 'application/vnd.bmi', + bmml: 'application/vnd.balsamiq.bmml+xml', + bmp: 'image/x-ms-bmp', + book: 'application/vnd.framemaker', + box: 'application/vnd.previewsystems.box', + boz: 'application/x-bzip2', + bpk: 'application/octet-stream', + bsp: 'model/vnd.valve.source.compiled-map', + btif: 'image/prs.btif', + buffer: 'application/octet-stream', + bz: 'application/x-bzip', + bz2: 'application/x-bzip2', + c: 'text/x-c', + c11amc: 'application/vnd.cluetrust.cartomobile-config', + c11amz: 'application/vnd.cluetrust.cartomobile-config-pkg', + c4d: 'application/vnd.clonk.c4group', + c4f: 'application/vnd.clonk.c4group', + c4g: 'application/vnd.clonk.c4group', + c4p: 'application/vnd.clonk.c4group', + c4u: 'application/vnd.clonk.c4group', + cab: 'application/vnd.ms-cab-compressed', + caf: 'audio/x-caf', + cap: 'application/vnd.tcpdump.pcap', + car: 'application/vnd.curl.car', + cat: 'application/vnd.ms-pki.seccat', + cb7: 'application/x-cbr', + cba: 'application/x-cbr', + cbr: 'application/x-cbr', + cbt: 'application/x-cbr', + cbz: 'application/x-cbr', + cc: 'text/x-c', + cco: 'application/x-cocoa', + cct: 'application/x-director', + ccxml: 'application/ccxml+xml', + cdbcmsg: 'application/vnd.contact.cmsg', + cdf: 'application/x-netcdf', + cdfx: 'application/cdfx+xml', + cdkey: 'application/vnd.mediastation.cdkey', + cdmia: 'application/cdmi-capability', + cdmic: 'application/cdmi-container', + cdmid: 'application/cdmi-domain', + cdmio: 'application/cdmi-object', + cdmiq: 'application/cdmi-queue', + cdx: 'chemical/x-cdx', + cdxml: 'application/vnd.chemdraw+xml', + cdy: 'application/vnd.cinderella', + cer: 'application/pkix-cert', + cfs: 'application/x-cfs-compressed', + cgm: 'image/cgm', + chat: 'application/x-chat', + chm: 'application/vnd.ms-htmlhelp', + chrt: 'application/vnd.kde.kchart', + cif: 'chemical/x-cif', + cii: 'application/vnd.anser-web-certificate-issue-initiation', + cil: 'application/vnd.ms-artgalry', + cjs: 'application/node', + cla: 'application/vnd.claymore', + class: 'application/java-vm', + clkk: 'application/vnd.crick.clicker.keyboard', + clkp: 'application/vnd.crick.clicker.palette', + clkt: 'application/vnd.crick.clicker.template', + clkw: 'application/vnd.crick.clicker.wordbank', + clkx: 'application/vnd.crick.clicker', + clp: 'application/x-msclip', + cmc: 'application/vnd.cosmocaller', + cmdf: 'chemical/x-cmdf', + cml: 'chemical/x-cml', + cmp: 'application/vnd.yellowriver-custom-menu', + cmx: 'image/x-cmx', + cod: 'application/vnd.rim.cod', + coffee: 'text/coffeescript', + com: 'application/x-msdownload', + conf: 'text/plain', + cpio: 'application/x-cpio', + cpp: 'text/x-c', + cpt: 'application/mac-compactpro', + crd: 'application/x-mscardfile', + crl: 'application/pkix-crl', + crt: 'application/x-x509-ca-cert', + crx: 'application/x-chrome-extension', + cryptonote: 'application/vnd.rig.cryptonote', + csh: 'application/x-csh', + csl: 'application/vnd.citationstyles.style+xml', + csml: 'chemical/x-csml', + csp: 'application/vnd.commonspace', + css: 'text/css', + cst: 'application/x-director', + csv: 'text/csv', + cu: 'application/cu-seeme', + curl: 'text/vnd.curl', + cww: 'application/prs.cww', + cxt: 'application/x-director', + cxx: 'text/x-c', + dae: 'model/vnd.collada+xml', + daf: 'application/vnd.mobius.daf', + dart: 'application/vnd.dart', + dataless: 'application/vnd.fdsn.seed', + davmount: 'application/davmount+xml', + dbf: 'application/vnd.dbf', + dbk: 'application/docbook+xml', + dcr: 'application/x-director', + dcurl: 'text/vnd.curl.dcurl', + dd2: 'application/vnd.oma.dd2+xml', + ddd: 'application/vnd.fujixerox.ddd', + ddf: 'application/vnd.syncml.dmddf+xml', + dds: 'image/vnd.ms-dds', + deb: 'application/x-debian-package', + def: 'text/plain', + deploy: 'application/octet-stream', + der: 'application/x-x509-ca-cert', + dfac: 'application/vnd.dreamfactory', + dgc: 'application/x-dgc-compressed', + dic: 'text/x-c', + dir: 'application/x-director', + dis: 'application/vnd.mobius.dis', + 'disposition-notification': 'message/disposition-notification', + dist: 'application/octet-stream', + distz: 'application/octet-stream', + djv: 'image/vnd.djvu', + djvu: 'image/vnd.djvu', + dll: 'application/x-msdownload', + dmg: 'application/x-apple-diskimage', + dmp: 'application/vnd.tcpdump.pcap', + dms: 'application/octet-stream', + dna: 'application/vnd.dna', + doc: 'application/msword', + docm: 'application/vnd.ms-word.document.macroenabled.12', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + dot: 'application/msword', + dotm: 'application/vnd.ms-word.template.macroenabled.12', + dotx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + dp: 'application/vnd.osgi.dp', + dpg: 'application/vnd.dpgraph', + dra: 'audio/vnd.dra', + drle: 'image/dicom-rle', + dsc: 'text/prs.lines.tag', + dssc: 'application/dssc+der', + dtb: 'application/x-dtbook+xml', + dtd: 'application/xml-dtd', + dts: 'audio/vnd.dts', + dtshd: 'audio/vnd.dts.hd', + dump: 'application/octet-stream', + dvb: 'video/vnd.dvb.file', + dvi: 'application/x-dvi', + dwd: 'application/atsc-dwd+xml', + dwf: 'model/vnd.dwf', + dwg: 'image/vnd.dwg', + dxf: 'image/vnd.dxf', + dxp: 'application/vnd.spotfire.dxp', + dxr: 'application/x-director', + ear: 'application/java-archive', + ecelp4800: 'audio/vnd.nuera.ecelp4800', + ecelp7470: 'audio/vnd.nuera.ecelp7470', + ecelp9600: 'audio/vnd.nuera.ecelp9600', + ecma: 'application/ecmascript', + edm: 'application/vnd.novadigm.edm', + edx: 'application/vnd.novadigm.edx', + efif: 'application/vnd.picsel', + ei6: 'application/vnd.pg.osasli', + elc: 'application/octet-stream', + emf: 'image/emf', + eml: 'message/rfc822', + emma: 'application/emma+xml', + emotionml: 'application/emotionml+xml', + emz: 'application/x-msmetafile', + eol: 'audio/vnd.digital-winds', + eot: 'application/vnd.ms-fontobject', + eps: 'application/postscript', + epub: 'application/epub+zip', + es: 'application/ecmascript', + es3: 'application/vnd.eszigno3+xml', + esa: 'application/vnd.osgi.subsystem', + esf: 'application/vnd.epson.esf', + et3: 'application/vnd.eszigno3+xml', + etx: 'text/x-setext', + eva: 'application/x-eva', + evy: 'application/x-envoy', + exe: 'application/x-msdownload', + exi: 'application/exi', + exp: 'application/express', + exr: 'image/aces', + ext: 'application/vnd.novadigm.ext', + ez: 'application/andrew-inset', + ez2: 'application/vnd.ezpix-album', + ez3: 'application/vnd.ezpix-package', + f: 'text/x-fortran', + f4v: 'video/x-f4v', + f77: 'text/x-fortran', + f90: 'text/x-fortran', + fbs: 'image/vnd.fastbidsheet', + fcdt: 'application/vnd.adobe.formscentral.fcdt', + fcs: 'application/vnd.isac.fcs', + fdf: 'application/vnd.fdf', + fdt: 'application/fdt+xml', + fe_launch: 'application/vnd.denovo.fcselayout-link', + fg5: 'application/vnd.fujitsu.oasysgp', + fgd: 'application/x-director', + fh: 'image/x-freehand', + fh4: 'image/x-freehand', + fh5: 'image/x-freehand', + fh7: 'image/x-freehand', + fhc: 'image/x-freehand', + fig: 'application/x-xfig', + fits: 'image/fits', + flac: 'audio/x-flac', + fli: 'video/x-fli', + flo: 'application/vnd.micrografx.flo', + flv: 'video/x-flv', + flw: 'application/vnd.kde.kivio', + flx: 'text/vnd.fmi.flexstor', + fly: 'text/vnd.fly', + fm: 'application/vnd.framemaker', + fnc: 'application/vnd.frogans.fnc', + fo: 'application/vnd.software602.filler.form+xml', + for: 'text/x-fortran', + fpx: 'image/vnd.fpx', + frame: 'application/vnd.framemaker', + fsc: 'application/vnd.fsc.weblaunch', + fst: 'image/vnd.fst', + ftc: 'application/vnd.fluxtime.clip', + fti: 'application/vnd.anser-web-funds-transfer-initiation', + fvt: 'video/vnd.fvt', + fxp: 'application/vnd.adobe.fxp', + fxpl: 'application/vnd.adobe.fxp', + fzs: 'application/vnd.fuzzysheet', + g2w: 'application/vnd.geoplan', + g3: 'image/g3fax', + g3w: 'application/vnd.geospace', + gac: 'application/vnd.groove-account', + gam: 'application/x-tads', + gbr: 'application/rpki-ghostbusters', + gca: 'application/x-gca-compressed', + gdl: 'model/vnd.gdl', + gdoc: 'application/vnd.google-apps.document', + ged: 'text/vnd.familysearch.gedcom', + geo: 'application/vnd.dynageo', + geojson: 'application/geo+json', + gex: 'application/vnd.geometry-explorer', + ggb: 'application/vnd.geogebra.file', + ggt: 'application/vnd.geogebra.tool', + ghf: 'application/vnd.groove-help', + gif: 'image/gif', + gim: 'application/vnd.groove-identity-message', + glb: 'model/gltf-binary', + gltf: 'model/gltf+json', + gml: 'application/gml+xml', + gmx: 'application/vnd.gmx', + gnumeric: 'application/x-gnumeric', + gph: 'application/vnd.flographit', + gpx: 'application/gpx+xml', + gqf: 'application/vnd.grafeq', + gqs: 'application/vnd.grafeq', + gram: 'application/srgs', + gramps: 'application/x-gramps-xml', + gre: 'application/vnd.geometry-explorer', + grv: 'application/vnd.groove-injector', + grxml: 'application/srgs+xml', + gsf: 'application/x-font-ghostscript', + gsheet: 'application/vnd.google-apps.spreadsheet', + gslides: 'application/vnd.google-apps.presentation', + gtar: 'application/x-gtar', + gtm: 'application/vnd.groove-tool-message', + gtw: 'model/vnd.gtw', + gv: 'text/vnd.graphviz', + gxf: 'application/gxf', + gxt: 'application/vnd.geonext', + gz: 'application/gzip', + h: 'text/x-c', + h261: 'video/h261', + h263: 'video/h263', + h264: 'video/h264', + hal: 'application/vnd.hal+xml', + hbci: 'application/vnd.hbci', + hbs: 'text/x-handlebars-template', + hdd: 'application/x-virtualbox-hdd', + hdf: 'application/x-hdf', + heic: 'image/heic', + heics: 'image/heic-sequence', + heif: 'image/heif', + heifs: 'image/heif-sequence', + hej2: 'image/hej2k', + held: 'application/atsc-held+xml', + hh: 'text/x-c', + hjson: 'application/hjson', + hlp: 'application/winhlp', + hpgl: 'application/vnd.hp-hpgl', + hpid: 'application/vnd.hp-hpid', + hps: 'application/vnd.hp-hps', + hqx: 'application/mac-binhex40', + hsj2: 'image/hsj2', + htc: 'text/x-component', + htke: 'application/vnd.kenameaapp', + htm: 'text/html', + html: 'text/html', + hvd: 'application/vnd.yamaha.hv-dic', + hvp: 'application/vnd.yamaha.hv-voice', + hvs: 'application/vnd.yamaha.hv-script', + i2g: 'application/vnd.intergeo', + icc: 'application/vnd.iccprofile', + ice: 'x-conference/x-cooltalk', + icm: 'application/vnd.iccprofile', + ico: 'image/x-icon', + ics: 'text/calendar', + ief: 'image/ief', + ifb: 'text/calendar', + ifm: 'application/vnd.shana.informed.formdata', + iges: 'model/iges', + igl: 'application/vnd.igloader', + igm: 'application/vnd.insors.igm', + igs: 'model/iges', + igx: 'application/vnd.micrografx.igx', + iif: 'application/vnd.shana.informed.interchange', + img: 'application/octet-stream', + imp: 'application/vnd.accpac.simply.imp', + ims: 'application/vnd.ms-ims', + in: 'text/plain', + ini: 'text/plain', + ink: 'application/inkml+xml', + inkml: 'application/inkml+xml', + install: 'application/x-install-instructions', + iota: 'application/vnd.astraea-software.iota', + ipfix: 'application/ipfix', + ipk: 'application/vnd.shana.informed.package', + irm: 'application/vnd.ibm.rights-management', + irp: 'application/vnd.irepository.package+xml', + iso: 'application/x-iso9660-image', + itp: 'application/vnd.shana.informed.formtemplate', + its: 'application/its+xml', + ivp: 'application/vnd.immervision-ivp', + ivu: 'application/vnd.immervision-ivu', + jad: 'text/vnd.sun.j2me.app-descriptor', + jade: 'text/jade', + jam: 'application/vnd.jam', + jar: 'application/java-archive', + jardiff: 'application/x-java-archive-diff', + java: 'text/x-java-source', + jhc: 'image/jphc', + jisp: 'application/vnd.jisp', + jls: 'image/jls', + jlt: 'application/vnd.hp-jlyt', + jng: 'image/x-jng', + jnlp: 'application/x-java-jnlp-file', + joda: 'application/vnd.joost.joda-archive', + jp2: 'image/jp2', + jpe: 'image/jpeg', + jpeg: 'image/jpeg', + jpf: 'image/jpx', + jpg: 'image/jpeg', + jpg2: 'image/jp2', + jpgm: 'video/jpm', + jpgv: 'video/jpeg', + jph: 'image/jph', + jpm: 'video/jpm', + jpx: 'image/jpx', + js: 'application/javascript', + json: 'application/json', + json5: 'application/json5', + jsonld: 'application/ld+json', + jsonml: 'application/jsonml+json', + jsx: 'text/jsx', + jxr: 'image/jxr', + jxra: 'image/jxra', + jxrs: 'image/jxrs', + jxs: 'image/jxs', + jxsc: 'image/jxsc', + jxsi: 'image/jxsi', + jxss: 'image/jxss', + kar: 'audio/midi', + karbon: 'application/vnd.kde.karbon', + kdbx: 'application/x-keepass2', + key: 'application/x-iwork-keynote-sffkey', + kfo: 'application/vnd.kde.kformula', + kia: 'application/vnd.kidspiration', + kml: 'application/vnd.google-earth.kml+xml', + kmz: 'application/vnd.google-earth.kmz', + kne: 'application/vnd.kinar', + knp: 'application/vnd.kinar', + kon: 'application/vnd.kde.kontour', + kpr: 'application/vnd.kde.kpresenter', + kpt: 'application/vnd.kde.kpresenter', + kpxx: 'application/vnd.ds-keypoint', + ksp: 'application/vnd.kde.kspread', + ktr: 'application/vnd.kahootz', + ktx: 'image/ktx', + ktx2: 'image/ktx2', + ktz: 'application/vnd.kahootz', + kwd: 'application/vnd.kde.kword', + kwt: 'application/vnd.kde.kword', + lasxml: 'application/vnd.las.las+xml', + latex: 'application/x-latex', + lbd: 'application/vnd.llamagraphics.life-balance.desktop', + lbe: 'application/vnd.llamagraphics.life-balance.exchange+xml', + les: 'application/vnd.hhe.lesson-player', + less: 'text/less', + lgr: 'application/lgr+xml', + lha: 'application/x-lzh-compressed', + link66: 'application/vnd.route66.link66+xml', + list: 'text/plain', + list3820: 'application/vnd.ibm.modcap', + listafp: 'application/vnd.ibm.modcap', + litcoffee: 'text/coffeescript', + lnk: 'application/x-ms-shortcut', + log: 'text/plain', + lostxml: 'application/lost+xml', + lrf: 'application/octet-stream', + lrm: 'application/vnd.ms-lrm', + ltf: 'application/vnd.frogans.ltf', + lua: 'text/x-lua', + luac: 'application/x-lua-bytecode', + lvp: 'audio/vnd.lucent.voice', + lwp: 'application/vnd.lotus-wordpro', + lzh: 'application/x-lzh-compressed', + m13: 'application/x-msmediaview', + m14: 'application/x-msmediaview', + m1v: 'video/mpeg', + m21: 'application/mp21', + m2a: 'audio/mpeg', + m2v: 'video/mpeg', + m3a: 'audio/mpeg', + m3u: 'audio/x-mpegurl', + m3u8: 'application/vnd.apple.mpegurl', + m4a: 'audio/x-m4a', + m4p: 'application/mp4', + m4s: 'video/iso.segment', + m4u: 'video/vnd.mpegurl', + m4v: 'video/x-m4v', + ma: 'application/mathematica', + mads: 'application/mads+xml', + maei: 'application/mmt-aei+xml', + mag: 'application/vnd.ecowin.chart', + maker: 'application/vnd.framemaker', + man: 'text/troff', + manifest: 'text/cache-manifest', + map: 'application/json', + mar: 'application/octet-stream', + markdown: 'text/markdown', + mathml: 'application/mathml+xml', + mb: 'application/mathematica', + mbk: 'application/vnd.mobius.mbk', + mbox: 'application/mbox', + mc1: 'application/vnd.medcalcdata', + mcd: 'application/vnd.mcd', + mcurl: 'text/vnd.curl.mcurl', + md: 'text/markdown', + mdb: 'application/x-msaccess', + mdi: 'image/vnd.ms-modi', + mdx: 'text/mdx', + me: 'text/troff', + mesh: 'model/mesh', + meta4: 'application/metalink4+xml', + metalink: 'application/metalink+xml', + mets: 'application/mets+xml', + mfm: 'application/vnd.mfmp', + mft: 'application/rpki-manifest', + mgp: 'application/vnd.osgeo.mapguide.package', + mgz: 'application/vnd.proteus.magazine', + mid: 'audio/midi', + midi: 'audio/midi', + mie: 'application/x-mie', + mif: 'application/vnd.mif', + mime: 'message/rfc822', + mj2: 'video/mj2', + mjp2: 'video/mj2', + mjs: 'application/javascript', + mk3d: 'video/x-matroska', + mka: 'audio/x-matroska', + mkd: 'text/x-markdown', + mks: 'video/x-matroska', + mkv: 'video/x-matroska', + mlp: 'application/vnd.dolby.mlp', + mmd: 'application/vnd.chipnuts.karaoke-mmd', + mmf: 'application/vnd.smaf', + mml: 'text/mathml', + mmr: 'image/vnd.fujixerox.edmics-mmr', + mng: 'video/x-mng', + mny: 'application/x-msmoney', + mobi: 'application/x-mobipocket-ebook', + mods: 'application/mods+xml', + mov: 'video/quicktime', + movie: 'video/x-sgi-movie', + mp2: 'audio/mpeg', + mp21: 'application/mp21', + mp2a: 'audio/mpeg', + mp3: 'audio/mpeg', + mp4: 'video/mp4', + mp4a: 'audio/mp4', + mp4s: 'application/mp4', + mp4v: 'video/mp4', + mpc: 'application/vnd.mophun.certificate', + mpd: 'application/dash+xml', + mpe: 'video/mpeg', + mpeg: 'video/mpeg', + mpg: 'video/mpeg', + mpg4: 'video/mp4', + mpga: 'audio/mpeg', + mpkg: 'application/vnd.apple.installer+xml', + mpm: 'application/vnd.blueice.multipass', + mpn: 'application/vnd.mophun.application', + mpp: 'application/vnd.ms-project', + mpt: 'application/vnd.ms-project', + mpy: 'application/vnd.ibm.minipay', + mqy: 'application/vnd.mobius.mqy', + mrc: 'application/marc', + mrcx: 'application/marcxml+xml', + ms: 'text/troff', + mscml: 'application/mediaservercontrol+xml', + mseed: 'application/vnd.fdsn.mseed', + mseq: 'application/vnd.mseq', + msf: 'application/vnd.epson.msf', + msg: 'application/vnd.ms-outlook', + msh: 'model/mesh', + msi: 'application/x-msdownload', + msl: 'application/vnd.mobius.msl', + msm: 'application/octet-stream', + msp: 'application/octet-stream', + msty: 'application/vnd.muvee.style', + mtl: 'model/mtl', + mts: 'model/vnd.mts', + mus: 'application/vnd.musician', + musd: 'application/mmt-usd+xml', + musicxml: 'application/vnd.recordare.musicxml+xml', + mvb: 'application/x-msmediaview', + mvt: 'application/vnd.mapbox-vector-tile', + mwf: 'application/vnd.mfer', + mxf: 'application/mxf', + mxl: 'application/vnd.recordare.musicxml', + mxmf: 'audio/mobile-xmf', + mxml: 'application/xv+xml', + mxs: 'application/vnd.triscape.mxs', + mxu: 'video/vnd.mpegurl', + 'n-gage': 'application/vnd.nokia.n-gage.symbian.install', + n3: 'text/n3', + nb: 'application/mathematica', + nbp: 'application/vnd.wolfram.player', + nc: 'application/x-netcdf', + ncx: 'application/x-dtbncx+xml', + nfo: 'text/x-nfo', + ngdat: 'application/vnd.nokia.n-gage.data', + nitf: 'application/vnd.nitf', + nlu: 'application/vnd.neurolanguage.nlu', + nml: 'application/vnd.enliven', + nnd: 'application/vnd.noblenet-directory', + nns: 'application/vnd.noblenet-sealer', + nnw: 'application/vnd.noblenet-web', + npx: 'image/vnd.net-fpx', + nq: 'application/n-quads', + nsc: 'application/x-conference', + nsf: 'application/vnd.lotus-notes', + nt: 'application/n-triples', + ntf: 'application/vnd.nitf', + numbers: 'application/x-iwork-numbers-sffnumbers', + nzb: 'application/x-nzb', + oa2: 'application/vnd.fujitsu.oasys2', + oa3: 'application/vnd.fujitsu.oasys3', + oas: 'application/vnd.fujitsu.oasys', + obd: 'application/x-msbinder', + obgx: 'application/vnd.openblox.game+xml', + obj: 'model/obj', + oda: 'application/oda', + odb: 'application/vnd.oasis.opendocument.database', + odc: 'application/vnd.oasis.opendocument.chart', + odf: 'application/vnd.oasis.opendocument.formula', + odft: 'application/vnd.oasis.opendocument.formula-template', + odg: 'application/vnd.oasis.opendocument.graphics', + odi: 'application/vnd.oasis.opendocument.image', + odm: 'application/vnd.oasis.opendocument.text-master', + odp: 'application/vnd.oasis.opendocument.presentation', + ods: 'application/vnd.oasis.opendocument.spreadsheet', + odt: 'application/vnd.oasis.opendocument.text', + oga: 'audio/ogg', + ogex: 'model/vnd.opengex', + ogg: 'audio/ogg', + ogv: 'video/ogg', + ogx: 'application/ogg', + omdoc: 'application/omdoc+xml', + onepkg: 'application/onenote', + onetmp: 'application/onenote', + onetoc: 'application/onenote', + onetoc2: 'application/onenote', + opf: 'application/oebps-package+xml', + opml: 'text/x-opml', + oprc: 'application/vnd.palm', + opus: 'audio/ogg', + org: 'text/x-org', + osf: 'application/vnd.yamaha.openscoreformat', + osfpvg: 'application/vnd.yamaha.openscoreformat.osfpvg+xml', + osm: 'application/vnd.openstreetmap.data+xml', + otc: 'application/vnd.oasis.opendocument.chart-template', + otf: 'font/otf', + otg: 'application/vnd.oasis.opendocument.graphics-template', + oth: 'application/vnd.oasis.opendocument.text-web', + oti: 'application/vnd.oasis.opendocument.image-template', + otp: 'application/vnd.oasis.opendocument.presentation-template', + ots: 'application/vnd.oasis.opendocument.spreadsheet-template', + ott: 'application/vnd.oasis.opendocument.text-template', + ova: 'application/x-virtualbox-ova', + ovf: 'application/x-virtualbox-ovf', + owl: 'application/rdf+xml', + oxps: 'application/oxps', + oxt: 'application/vnd.openofficeorg.extension', + p: 'text/x-pascal', + p10: 'application/pkcs10', + p12: 'application/x-pkcs12', + p7b: 'application/x-pkcs7-certificates', + p7c: 'application/pkcs7-mime', + p7m: 'application/pkcs7-mime', + p7r: 'application/x-pkcs7-certreqresp', + p7s: 'application/pkcs7-signature', + p8: 'application/pkcs8', + pac: 'application/x-ns-proxy-autoconfig', + pages: 'application/x-iwork-pages-sffpages', + pas: 'text/x-pascal', + paw: 'application/vnd.pawaafile', + pbd: 'application/vnd.powerbuilder6', + pbm: 'image/x-portable-bitmap', + pcap: 'application/vnd.tcpdump.pcap', + pcf: 'application/x-font-pcf', + pcl: 'application/vnd.hp-pcl', + pclxl: 'application/vnd.hp-pclxl', + pct: 'image/x-pict', + pcurl: 'application/vnd.curl.pcurl', + pcx: 'image/x-pcx', + pdb: 'application/x-pilot', + pde: 'text/x-processing', + pdf: 'application/pdf', + pem: 'application/x-x509-ca-cert', + pfa: 'application/x-font-type1', + pfb: 'application/x-font-type1', + pfm: 'application/x-font-type1', + pfr: 'application/font-tdpfr', + pfx: 'application/x-pkcs12', + pgm: 'image/x-portable-graymap', + pgn: 'application/x-chess-pgn', + pgp: 'application/pgp-encrypted', + php: 'application/x-httpd-php', + pic: 'image/x-pict', + pkg: 'application/octet-stream', + pki: 'application/pkixcmp', + pkipath: 'application/pkix-pkipath', + pkpass: 'application/vnd.apple.pkpass', + pl: 'application/x-perl', + plb: 'application/vnd.3gpp.pic-bw-large', + plc: 'application/vnd.mobius.plc', + plf: 'application/vnd.pocketlearn', + pls: 'application/pls+xml', + pm: 'application/x-perl', + pml: 'application/vnd.ctc-posml', + png: 'image/png', + pnm: 'image/x-portable-anymap', + portpkg: 'application/vnd.macports.portpkg', + pot: 'application/vnd.ms-powerpoint', + potm: 'application/vnd.ms-powerpoint.template.macroenabled.12', + potx: 'application/vnd.openxmlformats-officedocument.presentationml.template', + ppam: 'application/vnd.ms-powerpoint.addin.macroenabled.12', + ppd: 'application/vnd.cups-ppd', + ppm: 'image/x-portable-pixmap', + pps: 'application/vnd.ms-powerpoint', + ppsm: 'application/vnd.ms-powerpoint.slideshow.macroenabled.12', + ppsx: 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', + ppt: 'application/vnd.ms-powerpoint', + pptm: 'application/vnd.ms-powerpoint.presentation.macroenabled.12', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + pqa: 'application/vnd.palm', + prc: 'application/x-pilot', + pre: 'application/vnd.lotus-freelance', + prf: 'application/pics-rules', + provx: 'application/provenance+xml', + ps: 'application/postscript', + psb: 'application/vnd.3gpp.pic-bw-small', + psd: 'image/vnd.adobe.photoshop', + psf: 'application/x-font-linux-psf', + pskcxml: 'application/pskc+xml', + pti: 'image/prs.pti', + ptid: 'application/vnd.pvi.ptid1', + pub: 'application/x-mspublisher', + pvb: 'application/vnd.3gpp.pic-bw-var', + pwn: 'application/vnd.3m.post-it-notes', + pya: 'audio/vnd.ms-playready.media.pya', + pyv: 'video/vnd.ms-playready.media.pyv', + qam: 'application/vnd.epson.quickanime', + qbo: 'application/vnd.intu.qbo', + qfx: 'application/vnd.intu.qfx', + qps: 'application/vnd.publishare-delta-tree', + qt: 'video/quicktime', + qwd: 'application/vnd.quark.quarkxpress', + qwt: 'application/vnd.quark.quarkxpress', + qxb: 'application/vnd.quark.quarkxpress', + qxd: 'application/vnd.quark.quarkxpress', + qxl: 'application/vnd.quark.quarkxpress', + qxt: 'application/vnd.quark.quarkxpress', + ra: 'audio/x-realaudio', + ram: 'audio/x-pn-realaudio', + raml: 'application/raml+yaml', + rapd: 'application/route-apd+xml', + rar: 'application/x-rar-compressed', + ras: 'image/x-cmu-raster', + rcprofile: 'application/vnd.ipunplugged.rcprofile', + rdf: 'application/rdf+xml', + rdz: 'application/vnd.data-vision.rdz', + relo: 'application/p2p-overlay+xml', + rep: 'application/vnd.businessobjects', + res: 'application/x-dtbresource+xml', + rgb: 'image/x-rgb', + rif: 'application/reginfo+xml', + rip: 'audio/vnd.rip', + ris: 'application/x-research-info-systems', + rl: 'application/resource-lists+xml', + rlc: 'image/vnd.fujixerox.edmics-rlc', + rld: 'application/resource-lists-diff+xml', + rm: 'application/vnd.rn-realmedia', + rmi: 'audio/midi', + rmp: 'audio/x-pn-realaudio-plugin', + rms: 'application/vnd.jcp.javame.midlet-rms', + rmvb: 'application/vnd.rn-realmedia-vbr', + rnc: 'application/relax-ng-compact-syntax', + rng: 'application/xml', + roa: 'application/rpki-roa', + roff: 'text/troff', + rp9: 'application/vnd.cloanto.rp9', + rpm: 'application/x-redhat-package-manager', + rpss: 'application/vnd.nokia.radio-presets', + rpst: 'application/vnd.nokia.radio-preset', + rq: 'application/sparql-query', + rs: 'application/rls-services+xml', + rsat: 'application/atsc-rsat+xml', + rsd: 'application/rsd+xml', + rsheet: 'application/urc-ressheet+xml', + rss: 'application/rss+xml', + rtf: 'text/rtf', + rtx: 'text/richtext', + run: 'application/x-makeself', + rusd: 'application/route-usd+xml', + s: 'text/x-asm', + s3m: 'audio/s3m', + saf: 'application/vnd.yamaha.smaf-audio', + sass: 'text/x-sass', + sbml: 'application/sbml+xml', + sc: 'application/vnd.ibm.secure-container', + scd: 'application/x-msschedule', + scm: 'application/vnd.lotus-screencam', + scq: 'application/scvp-cv-request', + scs: 'application/scvp-cv-response', + scss: 'text/x-scss', + scurl: 'text/vnd.curl.scurl', + sda: 'application/vnd.stardivision.draw', + sdc: 'application/vnd.stardivision.calc', + sdd: 'application/vnd.stardivision.impress', + sdkd: 'application/vnd.solent.sdkm+xml', + sdkm: 'application/vnd.solent.sdkm+xml', + sdp: 'application/sdp', + sdw: 'application/vnd.stardivision.writer', + sea: 'application/x-sea', + see: 'application/vnd.seemail', + seed: 'application/vnd.fdsn.seed', + sema: 'application/vnd.sema', + semd: 'application/vnd.semd', + semf: 'application/vnd.semf', + senmlx: 'application/senml+xml', + sensmlx: 'application/sensml+xml', + ser: 'application/java-serialized-object', + setpay: 'application/set-payment-initiation', + setreg: 'application/set-registration-initiation', + 'sfd-hdstx': 'application/vnd.hydrostatix.sof-data', + sfs: 'application/vnd.spotfire.sfs', + sfv: 'text/x-sfv', + sgi: 'image/sgi', + sgl: 'application/vnd.stardivision.writer-global', + sgm: 'text/sgml', + sgml: 'text/sgml', + sh: 'application/x-sh', + shar: 'application/x-shar', + shex: 'text/shex', + shf: 'application/shf+xml', + shtml: 'text/html', + sid: 'image/x-mrsid-image', + sieve: 'application/sieve', + sig: 'application/pgp-signature', + sil: 'audio/silk', + silo: 'model/mesh', + sis: 'application/vnd.symbian.install', + sisx: 'application/vnd.symbian.install', + sit: 'application/x-stuffit', + sitx: 'application/x-stuffitx', + siv: 'application/sieve', + skd: 'application/vnd.koan', + skm: 'application/vnd.koan', + skp: 'application/vnd.koan', + skt: 'application/vnd.koan', + sldm: 'application/vnd.ms-powerpoint.slide.macroenabled.12', + sldx: 'application/vnd.openxmlformats-officedocument.presentationml.slide', + slim: 'text/slim', + slm: 'text/slim', + sls: 'application/route-s-tsid+xml', + slt: 'application/vnd.epson.salt', + sm: 'application/vnd.stepmania.stepchart', + smf: 'application/vnd.stardivision.math', + smi: 'application/smil+xml', + smil: 'application/smil+xml', + smv: 'video/x-smv', + smzip: 'application/vnd.stepmania.package', + snd: 'audio/basic', + snf: 'application/x-font-snf', + so: 'application/octet-stream', + spc: 'application/x-pkcs7-certificates', + spdx: 'text/spdx', + spf: 'application/vnd.yamaha.smaf-phrase', + spl: 'application/x-futuresplash', + spot: 'text/vnd.in3d.spot', + spp: 'application/scvp-vp-response', + spq: 'application/scvp-vp-request', + spx: 'audio/ogg', + sql: 'application/x-sql', + src: 'application/x-wais-source', + srt: 'application/x-subrip', + sru: 'application/sru+xml', + srx: 'application/sparql-results+xml', + ssdl: 'application/ssdl+xml', + sse: 'application/vnd.kodak-descriptor', + ssf: 'application/vnd.epson.ssf', + ssml: 'application/ssml+xml', + st: 'application/vnd.sailingtracker.track', + stc: 'application/vnd.sun.xml.calc.template', + std: 'application/vnd.sun.xml.draw.template', + stf: 'application/vnd.wt.stf', + sti: 'application/vnd.sun.xml.impress.template', + stk: 'application/hyperstudio', + stl: 'model/stl', + stpx: 'model/step+xml', + stpxz: 'model/step-xml+zip', + stpz: 'model/step+zip', + str: 'application/vnd.pg.format', + stw: 'application/vnd.sun.xml.writer.template', + styl: 'text/stylus', + stylus: 'text/stylus', + sub: 'text/vnd.dvb.subtitle', + sus: 'application/vnd.sus-calendar', + susp: 'application/vnd.sus-calendar', + sv4cpio: 'application/x-sv4cpio', + sv4crc: 'application/x-sv4crc', + svc: 'application/vnd.dvb.service', + svd: 'application/vnd.svd', + svg: 'image/svg+xml', + svgz: 'image/svg+xml', + swa: 'application/x-director', + swf: 'application/x-shockwave-flash', + swi: 'application/vnd.aristanetworks.swi', + swidtag: 'application/swid+xml', + sxc: 'application/vnd.sun.xml.calc', + sxd: 'application/vnd.sun.xml.draw', + sxg: 'application/vnd.sun.xml.writer.global', + sxi: 'application/vnd.sun.xml.impress', + sxm: 'application/vnd.sun.xml.math', + sxw: 'application/vnd.sun.xml.writer', + t: 'text/troff', + t3: 'application/x-t3vm-image', + t38: 'image/t38', + taglet: 'application/vnd.mynfc', + tao: 'application/vnd.tao.intent-module-archive', + tap: 'image/vnd.tencent.tap', + tar: 'application/x-tar', + tcap: 'application/vnd.3gpp2.tcap', + tcl: 'application/x-tcl', + td: 'application/urc-targetdesc+xml', + teacher: 'application/vnd.smart.teacher', + tei: 'application/tei+xml', + teicorpus: 'application/tei+xml', + tex: 'application/x-tex', + texi: 'application/x-texinfo', + texinfo: 'application/x-texinfo', + text: 'text/plain', + tfi: 'application/thraud+xml', + tfm: 'application/x-tex-tfm', + tfx: 'image/tiff-fx', + tga: 'image/x-tga', + thmx: 'application/vnd.ms-officetheme', + tif: 'image/tiff', + tiff: 'image/tiff', + tk: 'application/x-tcl', + tmo: 'application/vnd.tmobile-livetv', + toml: 'application/toml', + torrent: 'application/x-bittorrent', + tpl: 'application/vnd.groove-tool-template', + tpt: 'application/vnd.trid.tpt', + tr: 'text/troff', + tra: 'application/vnd.trueapp', + trig: 'application/trig', + trm: 'application/x-msterminal', + ts: 'video/mp2t', + tsd: 'application/timestamped-data', + tsv: 'text/tab-separated-values', + ttc: 'font/collection', + ttf: 'font/ttf', + ttl: 'text/turtle', + ttml: 'application/ttml+xml', + twd: 'application/vnd.simtech-mindmapper', + twds: 'application/vnd.simtech-mindmapper', + txd: 'application/vnd.genomatix.tuxedo', + txf: 'application/vnd.mobius.txf', + txt: 'text/plain', + u32: 'application/x-authorware-bin', + u8dsn: 'message/global-delivery-status', + u8hdr: 'message/global-headers', + u8mdn: 'message/global-disposition-notification', + u8msg: 'message/global', + ubj: 'application/ubjson', + udeb: 'application/x-debian-package', + ufd: 'application/vnd.ufdl', + ufdl: 'application/vnd.ufdl', + ulx: 'application/x-glulx', + umj: 'application/vnd.umajin', + unityweb: 'application/vnd.unity', + uoml: 'application/vnd.uoml+xml', + uri: 'text/uri-list', + uris: 'text/uri-list', + urls: 'text/uri-list', + usdz: 'model/vnd.usdz+zip', + ustar: 'application/x-ustar', + utz: 'application/vnd.uiq.theme', + uu: 'text/x-uuencode', + uva: 'audio/vnd.dece.audio', + uvd: 'application/vnd.dece.data', + uvf: 'application/vnd.dece.data', + uvg: 'image/vnd.dece.graphic', + uvh: 'video/vnd.dece.hd', + uvi: 'image/vnd.dece.graphic', + uvm: 'video/vnd.dece.mobile', + uvp: 'video/vnd.dece.pd', + uvs: 'video/vnd.dece.sd', + uvt: 'application/vnd.dece.ttml+xml', + uvu: 'video/vnd.uvvu.mp4', + uvv: 'video/vnd.dece.video', + uvva: 'audio/vnd.dece.audio', + uvvd: 'application/vnd.dece.data', + uvvf: 'application/vnd.dece.data', + uvvg: 'image/vnd.dece.graphic', + uvvh: 'video/vnd.dece.hd', + uvvi: 'image/vnd.dece.graphic', + uvvm: 'video/vnd.dece.mobile', + uvvp: 'video/vnd.dece.pd', + uvvs: 'video/vnd.dece.sd', + uvvt: 'application/vnd.dece.ttml+xml', + uvvu: 'video/vnd.uvvu.mp4', + uvvv: 'video/vnd.dece.video', + uvvx: 'application/vnd.dece.unspecified', + uvvz: 'application/vnd.dece.zip', + uvx: 'application/vnd.dece.unspecified', + uvz: 'application/vnd.dece.zip', + vbox: 'application/x-virtualbox-vbox', + 'vbox-extpack': 'application/x-virtualbox-vbox-extpack', + vcard: 'text/vcard', + vcd: 'application/x-cdlink', + vcf: 'text/x-vcard', + vcg: 'application/vnd.groove-vcard', + vcs: 'text/x-vcalendar', + vcx: 'application/vnd.vcx', + vdi: 'application/x-virtualbox-vdi', + vds: 'model/vnd.sap.vds', + vhd: 'application/x-virtualbox-vhd', + vis: 'application/vnd.visionary', + viv: 'video/vnd.vivo', + vmdk: 'application/x-virtualbox-vmdk', + vob: 'video/x-ms-vob', + vor: 'application/vnd.stardivision.writer', + vox: 'application/x-authorware-bin', + vrml: 'model/vrml', + vsd: 'application/vnd.visio', + vsf: 'application/vnd.vsf', + vss: 'application/vnd.visio', + vst: 'application/vnd.visio', + vsw: 'application/vnd.visio', + vtf: 'image/vnd.valve.source.texture', + vtt: 'text/vtt', + vtu: 'model/vnd.vtu', + vxml: 'application/voicexml+xml', + w3d: 'application/x-director', + wad: 'application/x-doom', + wadl: 'application/vnd.sun.wadl+xml', + war: 'application/java-archive', + wasm: 'application/wasm', + wav: 'audio/x-wav', + wax: 'audio/x-ms-wax', + wbmp: 'image/vnd.wap.wbmp', + wbs: 'application/vnd.criticaltools.wbs+xml', + wbxml: 'application/vnd.wap.wbxml', + wcm: 'application/vnd.ms-works', + wdb: 'application/vnd.ms-works', + wdp: 'image/vnd.ms-photo', + weba: 'audio/webm', + webapp: 'application/x-web-app-manifest+json', + webm: 'video/webm', + webmanifest: 'application/manifest+json', + webp: 'image/webp', + wg: 'application/vnd.pmi.widget', + wgt: 'application/widget', + wks: 'application/vnd.ms-works', + wm: 'video/x-ms-wm', + wma: 'audio/x-ms-wma', + wmd: 'application/x-ms-wmd', + wmf: 'image/wmf', + wml: 'text/vnd.wap.wml', + wmlc: 'application/vnd.wap.wmlc', + wmls: 'text/vnd.wap.wmlscript', + wmlsc: 'application/vnd.wap.wmlscriptc', + wmv: 'video/x-ms-wmv', + wmx: 'video/x-ms-wmx', + wmz: 'application/x-msmetafile', + woff: 'font/woff', + woff2: 'font/woff2', + wpd: 'application/vnd.wordperfect', + wpl: 'application/vnd.ms-wpl', + wps: 'application/vnd.ms-works', + wqd: 'application/vnd.wqd', + wri: 'application/x-mswrite', + wrl: 'model/vrml', + wsc: 'message/vnd.wfa.wsc', + wsdl: 'application/wsdl+xml', + wspolicy: 'application/wspolicy+xml', + wtb: 'application/vnd.webturbo', + wvx: 'video/x-ms-wvx', + x32: 'application/x-authorware-bin', + x3d: 'model/x3d+xml', + x3db: 'model/x3d+fastinfoset', + x3dbz: 'model/x3d+binary', + x3dv: 'model/x3d-vrml', + x3dvz: 'model/x3d+vrml', + x3dz: 'model/x3d+xml', + x_b: 'model/vnd.parasolid.transmit.binary', + x_t: 'model/vnd.parasolid.transmit.text', + xaml: 'application/xaml+xml', + xap: 'application/x-silverlight-app', + xar: 'application/vnd.xara', + xav: 'application/xcap-att+xml', + xbap: 'application/x-ms-xbap', + xbd: 'application/vnd.fujixerox.docuworks.binder', + xbm: 'image/x-xbitmap', + xca: 'application/xcap-caps+xml', + xcs: 'application/calendar+xml', + xdf: 'application/xcap-diff+xml', + xdm: 'application/vnd.syncml.dm+xml', + xdp: 'application/vnd.adobe.xdp+xml', + xdssc: 'application/dssc+xml', + xdw: 'application/vnd.fujixerox.docuworks', + xel: 'application/xcap-el+xml', + xenc: 'application/xenc+xml', + xer: 'application/patch-ops-error+xml', + xfdf: 'application/vnd.adobe.xfdf', + xfdl: 'application/vnd.xfdl', + xht: 'application/xhtml+xml', + xhtml: 'application/xhtml+xml', + xhvml: 'application/xv+xml', + xif: 'image/vnd.xiff', + xla: 'application/vnd.ms-excel', + xlam: 'application/vnd.ms-excel.addin.macroenabled.12', + xlc: 'application/vnd.ms-excel', + xlf: 'application/xliff+xml', + xlm: 'application/vnd.ms-excel', + xls: 'application/vnd.ms-excel', + xlsb: 'application/vnd.ms-excel.sheet.binary.macroenabled.12', + xlsm: 'application/vnd.ms-excel.sheet.macroenabled.12', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + xlt: 'application/vnd.ms-excel', + xltm: 'application/vnd.ms-excel.template.macroenabled.12', + xltx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + xlw: 'application/vnd.ms-excel', + xm: 'audio/xm', + xml: 'text/xml', + xns: 'application/xcap-ns+xml', + xo: 'application/vnd.olpc-sugar', + xop: 'application/xop+xml', + xpi: 'application/x-xpinstall', + xpl: 'application/xproc+xml', + xpm: 'image/x-xpixmap', + xpr: 'application/vnd.is-xpr', + xps: 'application/vnd.ms-xpsdocument', + xpw: 'application/vnd.intercon.formnet', + xpx: 'application/vnd.intercon.formnet', + xsd: 'application/xml', + xsl: 'application/xslt+xml', + xslt: 'application/xslt+xml', + xsm: 'application/vnd.syncml+xml', + xspf: 'application/xspf+xml', + xul: 'application/vnd.mozilla.xul+xml', + xvm: 'application/xv+xml', + xvml: 'application/xv+xml', + xwd: 'image/x-xwindowdump', + xyz: 'chemical/x-xyz', + xz: 'application/x-xz', + yaml: 'text/yaml', + yang: 'application/yang', + yin: 'application/yin+xml', + yml: 'text/yaml', + ymp: 'text/x-suse-ymp', + z1: 'application/x-zmachine', + z2: 'application/x-zmachine', + z3: 'application/x-zmachine', + z4: 'application/x-zmachine', + z5: 'application/x-zmachine', + z6: 'application/x-zmachine', + z7: 'application/x-zmachine', + z8: 'application/x-zmachine', + zaz: 'application/vnd.zzazz.deck+xml', + zip: 'application/zip', + zir: 'application/vnd.zul', + zirz: 'application/vnd.zul', + zmm: 'application/vnd.handheld-entertainment+xml', +} diff --git a/src/lib/link-meta/twitter.ts b/src/lib/link-meta/twitter.ts new file mode 100644 index 000000000..d785903c0 --- /dev/null +++ b/src/lib/link-meta/twitter.ts @@ -0,0 +1,20 @@ +export const extractTwitterMeta = ({ + pathname, +}: { + pathname: string +}): Record => { + const res = {title: 'Twitter'} + const parsedPathname = pathname.split('/') + if (parsedPathname.length <= 1 || parsedPathname[1].length <= 1) { + // Excluding one letter usernames as they're reserved by twitter for things like cases like twitter.com/i/articles/follows/-1675653703 + return res + } + const username = parsedPathname?.[1] + const isUserProfile = parsedPathname?.length === 2 + + res.title = isUserProfile + ? `@${username} on Twitter` + : `Tweet by @${username}` + + return res +} diff --git a/src/lib/link-meta/youtube.ts b/src/lib/link-meta/youtube.ts new file mode 100644 index 000000000..42eed51e8 --- /dev/null +++ b/src/lib/link-meta/youtube.ts @@ -0,0 +1,31 @@ +export const extractYoutubeMeta = (html: string): Record => { + const res: Record = {} + const youtubeTitleRegex = /"videoDetails":.*"title":"([^"]*)"/i + const youtubeDescriptionRegex = + /"videoDetails":.*"shortDescription":"([^"]*)"/i + const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i + const youtubeAvatarRegex = + /"avatar":{"thumbnails":\[{.*?url.*?url.*?url":"([^"]*)"/i + const youtubeTitleMatch = youtubeTitleRegex.exec(html) + const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html) + const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html) + const youtubeAvatarMatch = youtubeAvatarRegex.exec(html) + + if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) { + res.title = decodeURI(youtubeTitleMatch[1]) + } + if (youtubeDescriptionMatch && youtubeDescriptionMatch.length >= 1) { + res.description = decodeURI(youtubeDescriptionMatch[1]).replace( + /\\n/g, + '\n', + ) + } + if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) { + res.image = youtubeThumbnailMatch[1] + 'default.jpg' + } + if (!res.image && youtubeAvatarMatch && youtubeAvatarMatch.length >= 1) { + res.image = youtubeAvatarMatch[1] + } + + return res +} diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts new file mode 100644 index 000000000..fb0afdd60 --- /dev/null +++ b/src/lib/notifee.ts @@ -0,0 +1,74 @@ +import notifee, {EventType} from '@notifee/react-native' +import {AppBskyEmbedImages} from '@atproto/api' +import {RootStoreModel} from 'state/models/root-store' +import {TabPurpose} from 'state/models/navigation' +import {NotificationsViewItemModel} from 'state/models/notifications-view' +import {enforceLen} from 'lib/strings/helpers' + +export function init(store: RootStoreModel) { + store.onUnreadNotifications(count => notifee.setBadgeCount(count)) + store.onPushNotification(displayNotificationFromModel) + store.onSessionLoaded(() => { + // request notifications permission once the user has logged in + notifee.requestPermission() + }) + notifee.onForegroundEvent(async ({type}: {type: EventType}) => { + store.log.debug('Notifee foreground event', {type}) + if (type === EventType.PRESS) { + store.log.debug('User pressed a notifee, opening notifications') + store.nav.switchTo(TabPurpose.Notifs, true) + } + }) + notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent +} + +export function displayNotification( + title: string, + body?: string, + image?: string, +) { + const opts: {title: string; body?: string; ios?: any} = {title} + if (body) { + opts.body = enforceLen(body, 70, true) + } + if (image) { + opts.ios = { + attachments: [{url: image}], + } + } + return notifee.displayNotification(opts) +} + +export function displayNotificationFromModel( + notif: NotificationsViewItemModel, +) { + let author = notif.author.displayName || notif.author.handle + let title: string + let body: string = '' + if (notif.isUpvote) { + title = `${author} liked your post` + body = notif.additionalPost?.thread?.postRecord?.text || '' + } else if (notif.isRepost) { + title = `${author} reposted your post` + body = notif.additionalPost?.thread?.postRecord?.text || '' + } else if (notif.isMention) { + title = `${author} mentioned you` + body = notif.additionalPost?.thread?.postRecord?.text || '' + } else if (notif.isReply) { + title = `${author} replied to your post` + body = notif.additionalPost?.thread?.postRecord?.text || '' + } else if (notif.isFollow) { + title = 'New follower!' + body = `${author} has followed you` + } else { + return + } + let image + if ( + AppBskyEmbedImages.isPresented(notif.additionalPost?.thread?.post.embed) && + notif.additionalPost?.thread?.post.embed.images[0]?.thumb + ) { + image = notif.additionalPost.thread.post.embed.images[0].thumb + } + return displayNotification(title, body, image) +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 000000000..ab2c73ca6 --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,61 @@ +import {Alert} from 'react-native' +import { + check, + openSettings, + Permission, + PermissionStatus, + PERMISSIONS, + RESULTS, +} from 'react-native-permissions' + +export const PHOTO_LIBRARY = PERMISSIONS.IOS.PHOTO_LIBRARY +export const CAMERA = PERMISSIONS.IOS.CAMERA + +/** + * Returns `true` if the user has granted permission or hasn't made + * a decision yet. Returns `false` if unavailable or not granted. + */ +export async function hasAccess(perm: Permission): Promise { + const status = await check(perm) + return isntANo(status) +} + +export async function requestAccessIfNeeded( + perm: Permission, +): Promise { + if (await hasAccess(perm)) { + return true + } + let permDescription + if (perm === PHOTO_LIBRARY) { + permDescription = 'photo library' + } else if (perm === CAMERA) { + permDescription = 'camera' + } else { + return false + } + Alert.alert( + 'Permission needed', + `Bluesky does not have permission to access your ${permDescription}.`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + {text: 'Open Settings', onPress: () => openSettings()}, + ], + ) + return false +} + +export async function requestPhotoAccessIfNeeded() { + return requestAccessIfNeeded(PHOTO_LIBRARY) +} + +export async function requestCameraAccessIfNeeded() { + return requestAccessIfNeeded(CAMERA) +} + +function isntANo(status: PermissionStatus): boolean { + return status !== RESULTS.UNAVAILABLE && status !== RESULTS.BLOCKED +} diff --git a/src/lib/permissions.web.ts b/src/lib/permissions.web.ts new file mode 100644 index 000000000..5b69637ed --- /dev/null +++ b/src/lib/permissions.web.ts @@ -0,0 +1,22 @@ +/* +At the moment, Web doesn't have any equivalence for these. +*/ + +export const PHOTO_LIBRARY = '' +export const CAMERA = '' + +export async function hasAccess(_perm: any): Promise { + return true +} + +export async function requestAccessIfNeeded(_perm: any): Promise { + return true +} + +export async function requestPhotoAccessIfNeeded() { + return requestAccessIfNeeded(PHOTO_LIBRARY) +} + +export async function requestCameraAccessIfNeeded() { + return requestAccessIfNeeded(CAMERA) +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 000000000..dc5fb620f --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,52 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +export async function loadString(key: string): Promise { + try { + return await AsyncStorage.getItem(key) + } catch { + // not sure why this would fail... even reading the RN docs I'm unclear + return null + } +} + +export async function saveString(key: string, value: string): Promise { + try { + await AsyncStorage.setItem(key, value) + return true + } catch { + return false + } +} + +export async function load(key: string): Promise { + try { + const str = await AsyncStorage.getItem(key) + if (typeof str !== 'string') { + return null + } + return JSON.parse(str) + } catch { + return null + } +} + +export async function save(key: string, value: any): Promise { + try { + await AsyncStorage.setItem(key, JSON.stringify(value)) + return true + } catch { + return false + } +} + +export async function remove(key: string): Promise { + try { + await AsyncStorage.removeItem(key) + } catch {} +} + +export async function clear(): Promise { + try { + await AsyncStorage.clear() + } catch {} +} diff --git a/src/lib/strings.ts b/src/lib/strings.ts deleted file mode 100644 index 8b93fa933..000000000 --- a/src/lib/strings.ts +++ /dev/null @@ -1,267 +0,0 @@ -import {AtUri} from '../third-party/uri' -import {AppBskyFeedPost} from '@atproto/api' -type Entity = AppBskyFeedPost.Entity -import {PROD_SERVICE} from '../state' -import {isNetworkError} from './errors' -import TLDs from 'tlds' - -export const MAX_DISPLAY_NAME = 64 -export const MAX_DESCRIPTION = 256 - -export function pluralize(n: number, base: string, plural?: string): string { - if (n === 1) { - return base - } - if (plural) { - return plural - } - return base + 's' -} - -export function makeRecordUri( - didOrName: string, - collection: string, - rkey: string, -) { - const urip = new AtUri('at://host/') - urip.host = didOrName - urip.collection = collection - urip.rkey = rkey - return urip.toString() -} - -const MINUTE = 60 -const HOUR = MINUTE * 60 -const DAY = HOUR * 24 -const MONTH = DAY * 30 -const YEAR = DAY * 365 -export function ago(date: number | string | Date): string { - let ts: number - if (typeof date === 'string') { - ts = Number(new Date(date)) - } else if (date instanceof Date) { - ts = Number(date) - } else { - ts = date - } - const diffSeconds = Math.floor((Date.now() - ts) / 1e3) - if (diffSeconds < MINUTE) { - return `${diffSeconds}s` - } else if (diffSeconds < HOUR) { - return `${Math.floor(diffSeconds / MINUTE)}m` - } else if (diffSeconds < DAY) { - return `${Math.floor(diffSeconds / HOUR)}h` - } else if (diffSeconds < MONTH) { - return `${Math.floor(diffSeconds / DAY)}d` - } else if (diffSeconds < YEAR) { - return `${Math.floor(diffSeconds / MONTH)}mo` - } else { - return new Date(ts).toLocaleDateString() - } -} - -export function isValidDomain(str: string): boolean { - return !!TLDs.find(tld => { - let i = str.lastIndexOf(tld) - if (i === -1) { - return false - } - return str.charAt(i - 1) === '.' && i === str.length - tld.length - }) -} - -export function extractEntities( - text: string, - knownHandles?: Set, -): Entity[] | undefined { - let match - let ents: Entity[] = [] - { - // mentions - const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g - while ((match = re.exec(text))) { - if (knownHandles && !knownHandles.has(match[3])) { - continue // not a known handle - } else if (!match[3].includes('.')) { - continue // probably not a handle - } - const start = text.indexOf(match[3], match.index) - 1 - ents.push({ - type: 'mention', - value: match[3], - index: {start, end: start + match[3].length + 1}, - }) - } - } - { - // links - const re = - /(^|\s|\()((https?:\/\/[\S]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim - while ((match = re.exec(text))) { - let value = match[2] - if (!value.startsWith('http')) { - const domain = match.groups?.domain - if (!domain || !isValidDomain(domain)) { - continue - } - value = `https://${value}` - } - const start = text.indexOf(match[2], match.index) - const index = {start, end: start + match[2].length} - // strip ending puncuation - if (/[.,;!?]$/.test(value)) { - value = value.slice(0, -1) - index.end-- - } - if (/[)]$/.test(value) && !value.includes('(')) { - value = value.slice(0, -1) - index.end-- - } - ents.push({ - type: 'link', - value, - index, - }) - } - } - return ents.length > 0 ? ents : undefined -} - -interface DetectedLink { - link: string -} -type DetectedLinkable = string | DetectedLink -export function detectLinkables(text: string): DetectedLinkable[] { - const re = - /((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi - const segments = [] - let match - let start = 0 - while ((match = re.exec(text))) { - let matchIndex = match.index - let matchValue = match[0] - - if (match.groups?.domain && !isValidDomain(match.groups?.domain)) { - continue - } - - if (/\s|\(/.test(matchValue)) { - // HACK - // skip the starting space - // we have to do this because RN doesnt support negative lookaheads - // -prf - matchIndex++ - matchValue = matchValue.slice(1) - } - - // strip ending puncuation - if (/[.,;!?]$/.test(matchValue)) { - matchValue = matchValue.slice(0, -1) - } - if (/[)]$/.test(matchValue) && !matchValue.includes('(')) { - matchValue = matchValue.slice(0, -1) - } - - if (start !== matchIndex) { - segments.push(text.slice(start, matchIndex)) - } - segments.push({link: matchValue}) - start = matchIndex + matchValue.length - } - if (start < text.length) { - segments.push(text.slice(start)) - } - return segments -} - -export function makeValidHandle(str: string): string { - if (str.length > 20) { - str = str.slice(0, 20) - } - str = str.toLowerCase() - return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '') -} - -export function createFullHandle(name: string, domain: string): string { - name = (name || '').replace(/[.]+$/, '') - domain = (domain || '').replace(/^[.]+/, '') - return `${name}.${domain}` -} - -export function enforceLen(str: string, len: number, ellipsis = false): string { - str = str || '' - if (str.length > len) { - return str.slice(0, len) + (ellipsis ? '...' : '') - } - return str -} - -export function cleanError(str: any): string { - if (!str) { - return str - } - if (typeof str !== 'string') { - str = str.toString() - } - if (isNetworkError(str)) { - return 'Unable to connect. Please check your internet connection and try again.' - } - if (str.startsWith('Error: ')) { - return str.slice('Error: '.length) - } - return str -} - -export function toNiceDomain(url: string): string { - try { - const urlp = new URL(url) - if (`https://${urlp.host}` === PROD_SERVICE) { - return 'Bluesky Social' - } - return urlp.host - } catch (e) { - return url - } -} - -export function toShortUrl(url: string): string { - try { - const urlp = new URL(url) - const shortened = - urlp.host + - (urlp.pathname === '/' ? '' : urlp.pathname) + - urlp.search + - urlp.hash - if (shortened.length > 30) { - return shortened.slice(0, 27) + '...' - } - return shortened - } catch (e) { - return url - } -} - -export function toShareUrl(url: string): string { - if (!url.startsWith('https')) { - const urlp = new URL('https://bsky.app') - urlp.pathname = url - url = urlp.toString() - } - return url -} - -export function isBskyAppUrl(url: string): boolean { - return url.startsWith('https://bsky.app/') -} - -export function convertBskyAppUrlIfNeeded(url: string): string { - if (isBskyAppUrl(url)) { - try { - const urlp = new URL(url) - return urlp.pathname - } catch (e) { - console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e) - } - } - return url -} diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts new file mode 100644 index 000000000..0efcad335 --- /dev/null +++ b/src/lib/strings/errors.ts @@ -0,0 +1,23 @@ +export function cleanError(str: any): string { + if (!str) { + return '' + } + if (typeof str !== 'string') { + str = str.toString() + } + if (isNetworkError(str)) { + return 'Unable to connect. Please check your internet connection and try again.' + } + if (str.includes('Upstream Failure')) { + return 'The server appears to be experiencing issues. Please try again in a few moments.' + } + if (str.startsWith('Error: ')) { + return str.slice('Error: '.length) + } + return str +} + +export function isNetworkError(e: unknown) { + const str = String(e) + return str.includes('Abort') || str.includes('Network request failed') +} diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts new file mode 100644 index 000000000..3409a0312 --- /dev/null +++ b/src/lib/strings/handles.ts @@ -0,0 +1,13 @@ +export function makeValidHandle(str: string): string { + if (str.length > 20) { + str = str.slice(0, 20) + } + str = str.toLowerCase() + return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '') +} + +export function createFullHandle(name: string, domain: string): string { + name = (name || '').replace(/[.]+$/, '') + domain = (domain || '').replace(/^[.]+/, '') + return `${name}.${domain}` +} diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts new file mode 100644 index 000000000..183d53e31 --- /dev/null +++ b/src/lib/strings/helpers.ts @@ -0,0 +1,17 @@ +export function pluralize(n: number, base: string, plural?: string): string { + if (n === 1) { + return base + } + if (plural) { + return plural + } + return base + 's' +} + +export function enforceLen(str: string, len: number, ellipsis = false): string { + str = str || '' + if (str.length > len) { + return str.slice(0, len) + (ellipsis ? '...' : '') + } + return str +} diff --git a/src/lib/strings/mention-manip.ts b/src/lib/strings/mention-manip.ts new file mode 100644 index 000000000..1f7cbe434 --- /dev/null +++ b/src/lib/strings/mention-manip.ts @@ -0,0 +1,37 @@ +interface FoundMention { + value: string + index: number +} + +export function getMentionAt( + text: string, + cursorPos: number, +): FoundMention | undefined { + let re = /(^|\s)@([a-z0-9.]*)/gi + let match + while ((match = re.exec(text))) { + const spaceOffset = match[1].length + const index = match.index + spaceOffset + if ( + cursorPos >= index && + cursorPos <= index + match[0].length - spaceOffset + ) { + return {value: match[2], index} + } + } + return undefined +} + +export function insertMentionAt( + text: string, + cursorPos: number, + mention: string, +) { + const target = getMentionAt(text, cursorPos) + if (target) { + return `${text.slice(0, target.index)}@${mention} ${text.slice( + target.index + target.value.length + 1, // add 1 to include the "@" + )}` + } + return text +} diff --git a/src/lib/strings/rich-text-detection.ts b/src/lib/strings/rich-text-detection.ts new file mode 100644 index 000000000..386ed48e1 --- /dev/null +++ b/src/lib/strings/rich-text-detection.ts @@ -0,0 +1,107 @@ +import {AppBskyFeedPost} from '@atproto/api' +type Entity = AppBskyFeedPost.Entity +import {isValidDomain} from './url-helpers' + +export function extractEntities( + text: string, + knownHandles?: Set, +): Entity[] | undefined { + let match + let ents: Entity[] = [] + { + // mentions + const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g + while ((match = re.exec(text))) { + if (knownHandles && !knownHandles.has(match[3])) { + continue // not a known handle + } else if (!match[3].includes('.')) { + continue // probably not a handle + } + const start = text.indexOf(match[3], match.index) - 1 + ents.push({ + type: 'mention', + value: match[3], + index: {start, end: start + match[3].length + 1}, + }) + } + } + { + // links + const re = + /(^|\s|\()((https?:\/\/[\S]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim + while ((match = re.exec(text))) { + let value = match[2] + if (!value.startsWith('http')) { + const domain = match.groups?.domain + if (!domain || !isValidDomain(domain)) { + continue + } + value = `https://${value}` + } + const start = text.indexOf(match[2], match.index) + const index = {start, end: start + match[2].length} + // strip ending puncuation + if (/[.,;!?]$/.test(value)) { + value = value.slice(0, -1) + index.end-- + } + if (/[)]$/.test(value) && !value.includes('(')) { + value = value.slice(0, -1) + index.end-- + } + ents.push({ + type: 'link', + value, + index, + }) + } + } + return ents.length > 0 ? ents : undefined +} + +interface DetectedLink { + link: string +} +type DetectedLinkable = string | DetectedLink +export function detectLinkables(text: string): DetectedLinkable[] { + const re = + /((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi + const segments = [] + let match + let start = 0 + while ((match = re.exec(text))) { + let matchIndex = match.index + let matchValue = match[0] + + if (match.groups?.domain && !isValidDomain(match.groups?.domain)) { + continue + } + + if (/\s|\(/.test(matchValue)) { + // HACK + // skip the starting space + // we have to do this because RN doesnt support negative lookaheads + // -prf + matchIndex++ + matchValue = matchValue.slice(1) + } + + // strip ending puncuation + if (/[.,;!?]$/.test(matchValue)) { + matchValue = matchValue.slice(0, -1) + } + if (/[)]$/.test(matchValue) && !matchValue.includes('(')) { + matchValue = matchValue.slice(0, -1) + } + + if (start !== matchIndex) { + segments.push(text.slice(start, matchIndex)) + } + segments.push({link: matchValue}) + start = matchIndex + matchValue.length + } + if (start < text.length) { + segments.push(text.slice(start)) + } + return segments +} diff --git a/src/lib/strings/rich-text-sanitize.ts b/src/lib/strings/rich-text-sanitize.ts new file mode 100644 index 000000000..0b5895707 --- /dev/null +++ b/src/lib/strings/rich-text-sanitize.ts @@ -0,0 +1,32 @@ +import {RichText} from './rich-text' + +const EXCESS_SPACE_RE = /[\r\n]([\u00AD\u2060\u200D\u200C\u200B\s]*[\r\n]){2,}/ +const REPLACEMENT_STR = '\n\n' + +export function removeExcessNewlines(richText: RichText): RichText { + return clean(richText, EXCESS_SPACE_RE, REPLACEMENT_STR) +} + +// TODO: check on whether this works correctly with multi-byte codepoints +export function clean( + richText: RichText, + targetRegexp: RegExp, + replacementString: string, +): RichText { + richText = richText.clone() + + let match = richText.text.match(targetRegexp) + while (match && typeof match.index !== 'undefined') { + const oldText = richText.text + const removeStartIndex = match.index + const removeEndIndex = removeStartIndex + match[0].length + richText.delete(removeStartIndex, removeEndIndex) + if (richText.text === oldText) { + break // sanity check + } + richText.insert(removeStartIndex, replacementString) + match = richText.text.match(targetRegexp) + } + + return richText +} diff --git a/src/lib/strings/rich-text.ts b/src/lib/strings/rich-text.ts new file mode 100644 index 000000000..1df2144e0 --- /dev/null +++ b/src/lib/strings/rich-text.ts @@ -0,0 +1,216 @@ +/* += Rich Text Manipulation + +When we sanitize rich text, we have to update the entity indices as the +text is modified. This can be modeled as inserts() and deletes() of the +rich text string. The possible scenarios are outlined below, along with +their expected behaviors. + +NOTE: Slices are start inclusive, end exclusive + +== richTextInsert() + +Target string: + + 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l o w o r l d // string value + ^-------^ // target slice {start: 2, end: 7} + +Scenarios: + +A: ^ // insert "test" at 0 +B: ^ // insert "test" at 4 +C: ^ // insert "test" at 8 + +A = before -> move both by num added +B = inner -> move end by num added +C = after -> noop + +Results: + +A: 0 1 2 3 4 5 6 7 8 910 // string indices + t e s t h e l l o w // string value + ^-------^ // target slice {start: 6, end: 11} + +B: 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l t e s t o w // string value + ^---------------^ // target slice {start: 2, end: 11} + +C: 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l o w o t e s // string value + ^-------^ // target slice {start: 2, end: 7} + +== richTextDelete() + +Target string: + + 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l o w o r l d // string value + ^-------^ // target slice {start: 2, end: 7} + +Scenarios: + +A: ^---------------^ // remove slice {start: 0, end: 9} +B: ^-----^ // remove slice {start: 7, end: 11} +C: ^-----------^ // remove slice {start: 4, end: 11} +D: ^-^ // remove slice {start: 3, end: 5} +E: ^-----^ // remove slice {start: 1, end: 5} +F: ^-^ // remove slice {start: 0, end: 2} + +A = entirely outer -> delete slice +B = entirely after -> noop +C = partially after -> move end to remove-start +D = entirely inner -> move end by num removed +E = partially before -> move start to remove-start index, move end by num removed +F = entirely before -> move both by num removed + +Results: + +A: 0 1 2 3 4 5 6 7 8 910 // string indices + l d // string value + // target slice (deleted) + +B: 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l o w // string value + ^-------^ // target slice {start: 2, end: 7} + +C: 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l // string value + ^-^ // target slice {start: 2, end: 4} + +D: 0 1 2 3 4 5 6 7 8 910 // string indices + h e l w o r l d // string value + ^---^ // target slice {start: 2, end: 5} + +E: 0 1 2 3 4 5 6 7 8 910 // string indices + h w o r l d // string value + ^-^ // target slice {start: 1, end: 3} + +F: 0 1 2 3 4 5 6 7 8 910 // string indices + l l o w o r l d // string value + ^-------^ // target slice {start: 0, end: 5} + */ + +import cloneDeep from 'lodash.clonedeep' +import {AppBskyFeedPost} from '@atproto/api' +import {removeExcessNewlines} from './rich-text-sanitize' + +export type Entity = AppBskyFeedPost.Entity +export interface RichTextOpts { + cleanNewlines?: boolean +} + +export class RichText { + constructor( + public text: string, + public entities?: Entity[], + opts?: RichTextOpts, + ) { + if (opts?.cleanNewlines) { + removeExcessNewlines(this).copyInto(this) + } + } + + clone() { + return new RichText(this.text, cloneDeep(this.entities)) + } + + copyInto(target: RichText) { + target.text = this.text + target.entities = cloneDeep(this.entities) + } + + insert(insertIndex: number, insertText: string) { + this.text = + this.text.slice(0, insertIndex) + + insertText + + this.text.slice(insertIndex) + + if (!this.entities?.length) { + return this + } + + const numCharsAdded = insertText.length + for (const ent of this.entities) { + // see comment at top of file for labels of each scenario + // scenario A (before) + if (insertIndex <= ent.index.start) { + // move both by num added + ent.index.start += numCharsAdded + ent.index.end += numCharsAdded + } + // scenario B (inner) + else if (insertIndex >= ent.index.start && insertIndex < ent.index.end) { + // move end by num added + ent.index.end += numCharsAdded + } + // scenario C (after) + // noop + } + return this + } + + delete(removeStartIndex: number, removeEndIndex: number) { + this.text = + this.text.slice(0, removeStartIndex) + this.text.slice(removeEndIndex) + + if (!this.entities?.length) { + return this + } + + const numCharsRemoved = removeEndIndex - removeStartIndex + for (const ent of this.entities) { + // see comment at top of file for labels of each scenario + // scenario A (entirely outer) + if ( + removeStartIndex <= ent.index.start && + removeEndIndex >= ent.index.end + ) { + // delete slice (will get removed in final pass) + ent.index.start = 0 + ent.index.end = 0 + } + // scenario B (entirely after) + else if (removeStartIndex > ent.index.end) { + // noop + } + // scenario C (partially after) + else if ( + removeStartIndex > ent.index.start && + removeStartIndex <= ent.index.end && + removeEndIndex > ent.index.end + ) { + // move end to remove start + ent.index.end = removeStartIndex + } + // scenario D (entirely inner) + else if ( + removeStartIndex >= ent.index.start && + removeEndIndex <= ent.index.end + ) { + // move end by num removed + ent.index.end -= numCharsRemoved + } + // scenario E (partially before) + else if ( + removeStartIndex < ent.index.start && + removeEndIndex >= ent.index.start && + removeEndIndex <= ent.index.end + ) { + // move start to remove-start index, move end by num removed + ent.index.start = removeStartIndex + ent.index.end -= numCharsRemoved + } + // scenario F (entirely before) + else if (removeEndIndex < ent.index.start) { + // move both by num removed + ent.index.start -= numCharsRemoved + ent.index.end -= numCharsRemoved + } + } + + // filter out any entities that were made irrelevant + this.entities = this.entities.filter(ent => ent.index.start < ent.index.end) + return this + } +} diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts new file mode 100644 index 000000000..4f62eeba9 --- /dev/null +++ b/src/lib/strings/time.ts @@ -0,0 +1,29 @@ +const MINUTE = 60 +const HOUR = MINUTE * 60 +const DAY = HOUR * 24 +const MONTH = DAY * 30 +const YEAR = DAY * 365 +export function ago(date: number | string | Date): string { + let ts: number + if (typeof date === 'string') { + ts = Number(new Date(date)) + } else if (date instanceof Date) { + ts = Number(date) + } else { + ts = date + } + const diffSeconds = Math.floor((Date.now() - ts) / 1e3) + if (diffSeconds < MINUTE) { + return `${diffSeconds}s` + } else if (diffSeconds < HOUR) { + return `${Math.floor(diffSeconds / MINUTE)}m` + } else if (diffSeconds < DAY) { + return `${Math.floor(diffSeconds / HOUR)}h` + } else if (diffSeconds < MONTH) { + return `${Math.floor(diffSeconds / DAY)}d` + } else if (diffSeconds < YEAR) { + return `${Math.floor(diffSeconds / MONTH)}mo` + } else { + return new Date(ts).toLocaleDateString() + } +} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts new file mode 100644 index 000000000..a149f49c3 --- /dev/null +++ b/src/lib/strings/url-helpers.ts @@ -0,0 +1,108 @@ +import {AtUri} from '../../third-party/uri' +import {PROD_SERVICE} from 'state/index' +import TLDs from 'tlds' + +export function isValidDomain(str: string): boolean { + return !!TLDs.find(tld => { + let i = str.lastIndexOf(tld) + if (i === -1) { + return false + } + return str.charAt(i - 1) === '.' && i === str.length - tld.length + }) +} + +export function makeRecordUri( + didOrName: string, + collection: string, + rkey: string, +) { + const urip = new AtUri('at://host/') + urip.host = didOrName + urip.collection = collection + urip.rkey = rkey + return urip.toString() +} + +export function toNiceDomain(url: string): string { + try { + const urlp = new URL(url) + if (`https://${urlp.host}` === PROD_SERVICE) { + return 'Bluesky Social' + } + return urlp.host + } catch (e) { + return url + } +} + +export function toShortUrl(url: string): string { + try { + const urlp = new URL(url) + const shortened = + urlp.host + + (urlp.pathname === '/' ? '' : urlp.pathname) + + urlp.search + + urlp.hash + if (shortened.length > 30) { + return shortened.slice(0, 27) + '...' + } + return shortened + } catch (e) { + return url + } +} + +export function toShareUrl(url: string): string { + if (!url.startsWith('https')) { + const urlp = new URL('https://bsky.app') + urlp.pathname = url + url = urlp.toString() + } + return url +} + +export function isBskyAppUrl(url: string): boolean { + return url.startsWith('https://bsky.app/') +} + +export function convertBskyAppUrlIfNeeded(url: string): string { + if (isBskyAppUrl(url)) { + try { + const urlp = new URL(url) + return urlp.pathname + } catch (e) { + console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e) + } + } + return url +} + +export function getYoutubeVideoId(link: string): string | undefined { + let url + try { + url = new URL(link) + } catch (e) { + return undefined + } + + if ( + url.hostname !== 'www.youtube.com' && + url.hostname !== 'youtube.com' && + url.hostname !== 'youtu.be' + ) { + return undefined + } + if (url.hostname === 'youtu.be') { + const videoId = url.pathname.split('/')[1] + if (!videoId) { + return undefined + } + return videoId + } + const videoId = url.searchParams.get('v') as string + if (!videoId) { + return undefined + } + return videoId +} diff --git a/src/lib/styles.ts b/src/lib/styles.ts new file mode 100644 index 000000000..db6c03606 --- /dev/null +++ b/src/lib/styles.ts @@ -0,0 +1,218 @@ +import {StyleProp, StyleSheet, TextStyle} from 'react-native' +import {Theme, TypographyVariant} from './ThemeContext' + +// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest +export const colors = { + white: '#ffffff', + black: '#000000', + + gray1: '#F3F3F8', + gray2: '#E2E2E4', + gray3: '#B9B9C1', + gray4: '#8D8E96', + gray5: '#545664', + gray6: '#373942', + gray7: '#26272D', + gray8: '#101013', + + blue0: '#bfe1ff', + blue1: '#8bc7fd', + blue2: '#52acfe', + blue3: '#0085ff', + blue4: '#0062bd', + blue5: '#034581', + + red1: '#ffe6f2', + red2: '#fba2ce', + red3: '#ec4899', + red4: '#d1106f', + red5: '#97074e', + + pink1: '#f8ccff', + pink2: '#e966ff', + pink3: '#db00ff', + pink4: '#a601c1', + pink5: '#570066', + + purple1: '#ebdbff', + purple2: '#ba85ff', + purple3: '#9747ff', + purple4: '#6d00fa', + purple5: '#380080', + + green1: '#c1ffb8', + green2: '#27f406', + green3: '#20bc07', + green4: '#148203', + green5: '#082b03', + + unreadNotifBg: '#ebf6ff', +} + +export const gradients = { + blueLight: {start: '#5A71FA', end: colors.blue3}, // buttons + blue: {start: '#5E55FB', end: colors.blue3}, // fab + blueDark: {start: '#5F45E0', end: colors.blue3}, // avis, banner +} + +export const s = StyleSheet.create({ + // helpers + footerSpacer: {height: 100}, + contentContainer: {paddingBottom: 200}, + border1: {borderWidth: 1}, + + // font weights + fw600: {fontWeight: '600'}, + bold: {fontWeight: 'bold'}, + fw500: {fontWeight: '500'}, + semiBold: {fontWeight: '500'}, + fw400: {fontWeight: '400'}, + normal: {fontWeight: '400'}, + fw300: {fontWeight: '300'}, + light: {fontWeight: '300'}, + fw200: {fontWeight: '200'}, + + // text decoration + underline: {textDecorationLine: 'underline'}, + + // font sizes + f9: {fontSize: 9}, + f10: {fontSize: 10}, + f11: {fontSize: 11}, + f12: {fontSize: 12}, + f13: {fontSize: 13}, + f14: {fontSize: 14}, + f15: {fontSize: 15}, + f16: {fontSize: 16}, + f17: {fontSize: 17}, + f18: {fontSize: 18}, + + // line heights + ['lh13-1']: {lineHeight: 13}, + ['lh13-1.3']: {lineHeight: 16.9}, // 1.3 of 13px + ['lh14-1']: {lineHeight: 14}, + ['lh14-1.3']: {lineHeight: 18.2}, // 1.3 of 14px + ['lh15-1']: {lineHeight: 15}, + ['lh15-1.3']: {lineHeight: 19.5}, // 1.3 of 15px + ['lh16-1']: {lineHeight: 16}, + ['lh16-1.3']: {lineHeight: 20.8}, // 1.3 of 16px + ['lh17-1']: {lineHeight: 17}, + ['lh17-1.3']: {lineHeight: 22.1}, // 1.3 of 17px + ['lh18-1']: {lineHeight: 18}, + ['lh18-1.3']: {lineHeight: 23.4}, // 1.3 of 18px + + // margins + mr2: {marginRight: 2}, + mr5: {marginRight: 5}, + mr10: {marginRight: 10}, + ml2: {marginLeft: 2}, + ml5: {marginLeft: 5}, + ml10: {marginLeft: 10}, + mt2: {marginTop: 2}, + mt5: {marginTop: 5}, + mt10: {marginTop: 10}, + mb2: {marginBottom: 2}, + mb5: {marginBottom: 5}, + mb10: {marginBottom: 10}, + + // paddings + p2: {padding: 2}, + p5: {padding: 5}, + p10: {padding: 10}, + p20: {padding: 20}, + pr2: {paddingRight: 2}, + pr5: {paddingRight: 5}, + pr10: {paddingRight: 10}, + pr20: {paddingRight: 20}, + pl2: {paddingLeft: 2}, + pl5: {paddingLeft: 5}, + pl10: {paddingLeft: 10}, + pl20: {paddingLeft: 20}, + pt2: {paddingTop: 2}, + pt5: {paddingTop: 5}, + pt10: {paddingTop: 10}, + pt20: {paddingTop: 20}, + pb2: {paddingBottom: 2}, + pb5: {paddingBottom: 5}, + pb10: {paddingBottom: 10}, + pb20: {paddingBottom: 20}, + + // flex + flexRow: {flexDirection: 'row'}, + flexCol: {flexDirection: 'column'}, + flex1: {flex: 1}, + alignCenter: {alignItems: 'center'}, + alignBaseline: {alignItems: 'baseline'}, + + // position + absolute: {position: 'absolute'}, + + // dimensions + w100pct: {width: '100%'}, + h100pct: {height: '100%'}, + + // text align + textLeft: {textAlign: 'left'}, + textCenter: {textAlign: 'center'}, + textRight: {textAlign: 'right'}, + + // colors + white: {color: colors.white}, + black: {color: colors.black}, + + gray1: {color: colors.gray1}, + gray2: {color: colors.gray2}, + gray3: {color: colors.gray3}, + gray4: {color: colors.gray4}, + gray5: {color: colors.gray5}, + + blue1: {color: colors.blue1}, + blue2: {color: colors.blue2}, + blue3: {color: colors.blue3}, + blue4: {color: colors.blue4}, + blue5: {color: colors.blue5}, + + red1: {color: colors.red1}, + red2: {color: colors.red2}, + red3: {color: colors.red3}, + red4: {color: colors.red4}, + red5: {color: colors.red5}, + + pink1: {color: colors.pink1}, + pink2: {color: colors.pink2}, + pink3: {color: colors.pink3}, + pink4: {color: colors.pink4}, + pink5: {color: colors.pink5}, + + purple1: {color: colors.purple1}, + purple2: {color: colors.purple2}, + purple3: {color: colors.purple3}, + purple4: {color: colors.purple4}, + purple5: {color: colors.purple5}, + + green1: {color: colors.green1}, + green2: {color: colors.green2}, + green3: {color: colors.green3}, + green4: {color: colors.green4}, + green5: {color: colors.green5}, +}) + +export function lh( + theme: Theme, + type: TypographyVariant, + height: number, +): TextStyle { + return { + lineHeight: (theme.typography[type].fontSize || 16) * height, + } +} + +export function addStyle( + base: StyleProp, + addedStyle: StyleProp, +): StyleProp { + if (Array.isArray(base)) { + return base.concat([addedStyle]) + } + return [base, addedStyle] +} diff --git a/src/lib/themes.ts b/src/lib/themes.ts new file mode 100644 index 000000000..c544eebf2 --- /dev/null +++ b/src/lib/themes.ts @@ -0,0 +1,307 @@ +import {Platform} from 'react-native' +import type {Theme} from './ThemeContext' +import {colors} from './styles' + +export const defaultTheme: Theme = { + colorScheme: 'light', + palette: { + default: { + background: colors.white, + backgroundLight: colors.gray1, + text: colors.black, + textLight: colors.gray5, + textInverted: colors.white, + link: colors.blue3, + border: '#f0e9e9', + borderDark: '#e0d9d9', + icon: colors.gray4, + + // non-standard + textVeryLight: colors.gray4, + replyLine: colors.gray2, + replyLineDot: colors.gray3, + unreadNotifBg: '#ebf6ff', + postCtrl: '#71768A', + brandText: '#0066FF', + emptyStateIcon: '#B6B6C9', + }, + primary: { + background: colors.blue3, + backgroundLight: colors.blue2, + text: colors.white, + textLight: colors.blue0, + textInverted: colors.blue3, + link: colors.blue0, + border: colors.blue4, + borderDark: colors.blue5, + icon: colors.blue4, + }, + secondary: { + background: colors.green3, + backgroundLight: colors.green2, + text: colors.white, + textLight: colors.green1, + textInverted: colors.green4, + link: colors.green1, + border: colors.green4, + borderDark: colors.green5, + icon: colors.green4, + }, + inverted: { + background: colors.black, + backgroundLight: colors.gray6, + text: colors.white, + textLight: colors.gray3, + textInverted: colors.black, + link: colors.blue2, + border: colors.gray3, + borderDark: colors.gray2, + icon: colors.gray5, + }, + error: { + background: colors.red3, + backgroundLight: colors.red2, + text: colors.white, + textLight: colors.red1, + textInverted: colors.red3, + link: colors.red1, + border: colors.red4, + borderDark: colors.red5, + icon: colors.red4, + }, + }, + shapes: { + button: { + // TODO + }, + bigButton: { + // TODO + }, + smallButton: { + // TODO + }, + }, + typography: { + 'xl-thin': { + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '300', + }, + xl: { + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + }, + 'xl-medium': { + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'xl-bold': { + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '700', + }, + 'xl-heavy': { + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '800', + }, + 'lg-thin': { + fontSize: 16, + letterSpacing: 0.25, + fontWeight: '300', + }, + lg: { + fontSize: 16, + letterSpacing: 0.25, + fontWeight: '400', + }, + 'lg-medium': { + fontSize: 16, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'lg-bold': { + fontSize: 16, + letterSpacing: 0.25, + fontWeight: '700', + }, + 'lg-heavy': { + fontSize: 16, + letterSpacing: 0.25, + fontWeight: '800', + }, + 'md-thin': { + fontSize: 15, + letterSpacing: 0.25, + fontWeight: '300', + }, + md: { + fontSize: 15, + letterSpacing: 0.25, + fontWeight: '400', + }, + 'md-medium': { + fontSize: 15, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'md-bold': { + fontSize: 15, + letterSpacing: 0.25, + fontWeight: '700', + }, + 'md-heavy': { + fontSize: 15, + letterSpacing: 0.25, + fontWeight: '800', + }, + 'sm-thin': { + fontSize: 14, + letterSpacing: 0.25, + fontWeight: '300', + }, + sm: { + fontSize: 14, + letterSpacing: 0.25, + fontWeight: '400', + }, + 'sm-medium': { + fontSize: 14, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'sm-bold': { + fontSize: 14, + letterSpacing: 0.25, + fontWeight: '700', + }, + 'sm-heavy': { + fontSize: 14, + letterSpacing: 0.25, + fontWeight: '800', + }, + 'xs-thin': { + fontSize: 13, + letterSpacing: 0.25, + fontWeight: '300', + }, + xs: { + fontSize: 13, + letterSpacing: 0.25, + fontWeight: '400', + }, + 'xs-medium': { + fontSize: 13, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'xs-bold': { + fontSize: 13, + letterSpacing: 0.25, + fontWeight: '700', + }, + 'xs-heavy': { + fontSize: 13, + letterSpacing: 0.25, + fontWeight: '800', + }, + + 'title-2xl': { + fontSize: 34, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'title-xl': { + fontSize: 28, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'title-lg': { + fontSize: 22, + fontWeight: '500', + }, + title: { + fontWeight: '500', + fontSize: 20, + letterSpacing: 0.15, + }, + 'title-sm': { + fontWeight: 'bold', + fontSize: 17, + letterSpacing: 0.15, + }, + 'post-text': { + fontSize: 16, + letterSpacing: 0.2, + fontWeight: '400', + }, + 'post-text-lg': { + fontSize: 22, + letterSpacing: 0.4, + fontWeight: '400', + }, + 'button-lg': { + fontWeight: '500', + fontSize: 18, + letterSpacing: 0.5, + }, + button: { + fontWeight: '500', + fontSize: 14, + letterSpacing: 0.5, + }, + mono: { + fontSize: 14, + fontFamily: Platform.OS === 'android' ? 'monospace' : 'Courier New', + }, + }, +} + +export const darkTheme: Theme = { + ...defaultTheme, + colorScheme: 'dark', + palette: { + ...defaultTheme.palette, + default: { + background: colors.gray8, + backgroundLight: colors.gray6, + text: colors.white, + textLight: colors.gray3, + textInverted: colors.black, + link: colors.blue3, + border: colors.gray6, + borderDark: colors.gray5, + icon: colors.gray4, + + // non-standard + textVeryLight: colors.gray4, + replyLine: colors.gray5, + replyLineDot: colors.gray6, + unreadNotifBg: colors.blue5, + postCtrl: '#61657A', + brandText: '#0085ff', + emptyStateIcon: colors.gray4, + }, + primary: { + ...defaultTheme.palette.primary, + textInverted: colors.blue2, + }, + secondary: { + ...defaultTheme.palette.secondary, + textInverted: colors.green2, + }, + inverted: { + background: colors.white, + backgroundLight: colors.gray2, + text: colors.black, + textLight: colors.gray5, + textInverted: colors.white, + link: colors.blue3, + border: colors.gray3, + borderDark: colors.gray4, + icon: colors.gray1, + }, + }, +} diff --git a/src/lib/type-guards.ts b/src/lib/type-guards.ts new file mode 100644 index 000000000..8fe651ffb --- /dev/null +++ b/src/lib/type-guards.ts @@ -0,0 +1,14 @@ +export function isObj(v: unknown): v is Record { + return !!v && typeof v === 'object' +} + +export function hasProp( + data: object, + prop: K, +): data is Record { + return prop in data +} + +export function isStrArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every(item => typeof item === 'string') +} diff --git a/src/state/index.ts b/src/state/index.ts index 654c15af7..61b85e51d 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -1,9 +1,9 @@ import {autorun} from 'mobx' -import {Platform} from 'react-native' -import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api' +import {AppState, Platform} from 'react-native' +import {AtpAgent} from '@atproto/api' import {RootStoreModel} from './models/root-store' -import * as apiPolyfill from './lib/api-polyfill' -import * as storage from './lib/storage' +import * as apiPolyfill from 'lib/api/api-polyfill' +import * as storage from 'lib/storage' export const LOCAL_DEV_SERVICE = Platform.OS === 'ios' ? 'http://localhost:2583' : 'http://10.0.2.2:2583' @@ -19,8 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) { apiPolyfill.doPolyfill() - const api = AtpApi.service(serviceUri) as SessionServiceClient - rootStore = new RootStoreModel(api) + rootStore = new RootStoreModel(new AtpAgent({service: serviceUri})) try { data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} rootStore.log.debug('Initial hydrate', {hasSession: !!data.session}) @@ -28,25 +27,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) { } catch (e: any) { rootStore.log.error('Failed to load state from storage', e) } - - rootStore.session - .connect() - .then(() => { - rootStore.log.debug('Session connected') - return rootStore.fetchStateUpdate() - }) - .catch((e: any) => { - rootStore.log.warn('Failed initial connect', e) - }) - // @ts-ignore .on() is correct -prf - api.sessionManager.on('session', () => { - if (!api.sessionManager.session && rootStore.session.hasSession) { - // reset session - rootStore.session.clear() - } else if (api.sessionManager.session) { - rootStore.session.updateAuthTokens(api.sessionManager.session) - } - }) + rootStore.attemptSessionResumption() // track changes & save to storage autorun(() => { @@ -56,7 +37,14 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) { // periodic state fetch setInterval(() => { - rootStore.fetchStateUpdate() + // NOTE + // this must ONLY occur when the app is active, as the bg-fetch handler + // will wake up the thread and cause this interval to fire, which in + // turn schedules a bunch of work at a poor time + // -prf + if (AppState.currentState === 'active') { + rootStore.updateSessionState() + } }, STATE_FETCH_INTERVAL) return rootStore diff --git a/src/state/lib/api-polyfill.ts b/src/state/lib/api-polyfill.ts deleted file mode 100644 index df397cbd9..000000000 --- a/src/state/lib/api-polyfill.ts +++ /dev/null @@ -1,79 +0,0 @@ -import {sessionClient as AtpApi} from '@atproto/api' -import RNFS from 'react-native-fs' - -const TIMEOUT = 10e3 // 10s - -export function doPolyfill() { - AtpApi.xrpc.fetch = fetchHandler -} - -interface FetchHandlerResponse { - status: number - headers: Record - body: ArrayBuffer | undefined -} - -async function fetchHandler( - reqUri: string, - reqMethod: string, - reqHeaders: Record, - reqBody: any, -): Promise { - const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type'] - if (reqMimeType && reqMimeType.startsWith('application/json')) { - reqBody = JSON.stringify(reqBody) - } else if ( - typeof reqBody === 'string' && - (reqBody.startsWith('/') || reqBody.startsWith('file:')) - ) { - if (reqBody.endsWith('.jpeg') || reqBody.endsWith('.jpg')) { - // HACK - // React native has a bug that inflates the size of jpegs on upload - // we get around that by renaming the file ext to .bin - // see https://github.com/facebook/react-native/issues/27099 - // -prf - const newPath = reqBody.replace(/\.jpe?g$/, '.bin') - await RNFS.moveFile(reqBody, newPath) - reqBody = newPath - } - // NOTE - // React native treats bodies with {uri: string} as file uploads to pull from cache - // -prf - reqBody = {uri: reqBody} - } - - const controller = new AbortController() - const to = setTimeout(() => controller.abort(), TIMEOUT) - - const res = await fetch(reqUri, { - method: reqMethod, - headers: reqHeaders, - body: reqBody, - signal: controller.signal, - }) - - const resStatus = res.status - const resHeaders: Record = {} - res.headers.forEach((value: string, key: string) => { - resHeaders[key] = value - }) - const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type'] - let resBody - if (resMimeType) { - if (resMimeType.startsWith('application/json')) { - resBody = await res.json() - } else if (resMimeType.startsWith('text/')) { - resBody = await res.text() - } else { - throw new Error('TODO: non-textual response body') - } - } - - clearTimeout(to) - - return { - status: resStatus, - headers: resHeaders, - body: resBody, - } -} diff --git a/src/state/lib/api-polyfill.web.ts b/src/state/lib/api-polyfill.web.ts deleted file mode 100644 index 1469cf905..000000000 --- a/src/state/lib/api-polyfill.web.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function doPolyfill() { - // TODO needed? native fetch may work fine -prf - // AtpApi.xrpc.fetch = fetchHandler -} diff --git a/src/state/lib/api.ts b/src/state/lib/api.ts deleted file mode 100644 index 7ae04f52d..000000000 --- a/src/state/lib/api.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * The environment is a place where services and shared dependencies between - * models live. They are made available to every model via dependency injection. - */ - -// import {ReactNativeStore} from './auth' -import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' -import {AtUri} from '../../third-party/uri' -import {RootStoreModel} from '../models/root-store' -import {extractEntities} from '../../lib/strings' -import {isNetworkError} from '../../lib/errors' -import {LinkMeta} from '../../lib/link-meta' -import {Image} from '../../lib/images' - -export interface ExternalEmbedDraft { - uri: string - isLoading: boolean - meta?: LinkMeta - localThumb?: Image -} - -export async function post( - store: RootStoreModel, - text: string, - replyTo?: string, - extLink?: ExternalEmbedDraft, - images?: string[], - knownHandles?: Set, - onStateChange?: (state: string) => void, -) { - let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined - let reply - - onStateChange?.('Processing...') - const entities = extractEntities(text, knownHandles) - if (entities) { - for (const ent of entities) { - if (ent.type === 'mention') { - const prof = await store.profiles.getProfile(ent.value) - ent.value = prof.data.did - } - } - } - - if (images?.length) { - embed = { - $type: 'app.bsky.embed.images', - images: [], - } as AppBskyEmbedImages.Main - let i = 1 - for (const image of images) { - onStateChange?.(`Uploading image #${i++}...`) - const res = await store.api.com.atproto.blob.upload( - image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts - {encoding: 'image/jpeg'}, - ) - embed.images.push({ - image: { - cid: res.data.cid, - mimeType: 'image/jpeg', - }, - alt: '', // TODO supply alt text - }) - } - } - - if (!embed && extLink) { - let thumb - if (extLink.localThumb) { - onStateChange?.('Uploading link thumbnail...') - let encoding - if (extLink.localThumb.path.endsWith('.png')) { - encoding = 'image/png' - } else if ( - extLink.localThumb.path.endsWith('.jpeg') || - extLink.localThumb.path.endsWith('.jpg') - ) { - encoding = 'image/jpeg' - } else { - store.log.warn( - 'Unexpected image format for thumbnail, skipping', - extLink.localThumb.path, - ) - } - if (encoding) { - const thumbUploadRes = await store.api.com.atproto.blob.upload( - extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts - {encoding}, - ) - thumb = { - cid: thumbUploadRes.data.cid, - mimeType: encoding, - } - } - } - embed = { - $type: 'app.bsky.embed.external', - external: { - uri: extLink.uri, - title: extLink.meta?.title || '', - description: extLink.meta?.description || '', - thumb, - }, - } as AppBskyEmbedExternal.Main - } - - if (replyTo) { - const replyToUrip = new AtUri(replyTo) - const parentPost = await store.api.app.bsky.feed.post.get({ - user: replyToUrip.host, - rkey: replyToUrip.rkey, - }) - if (parentPost) { - const parentRef = { - uri: parentPost.uri, - cid: parentPost.cid, - } - reply = { - root: parentPost.value.reply?.root || parentRef, - parent: parentRef, - } - } - } - - try { - onStateChange?.('Posting...') - return await store.api.app.bsky.feed.post.create( - {did: store.me.did || ''}, - { - text, - reply, - embed, - entities, - createdAt: new Date().toISOString(), - }, - ) - } catch (e: any) { - console.error(`Failed to create post: ${e.toString()}`) - if (isNetworkError(e)) { - throw new Error( - 'Post failed to upload. Please check your Internet connection and try again.', - ) - } else { - throw e - } - } -} - -export async function repost(store: RootStoreModel, uri: string, cid: string) { - return await store.api.app.bsky.feed.repost.create( - {did: store.me.did || ''}, - { - subject: {uri, cid}, - createdAt: new Date().toISOString(), - }, - ) -} - -export async function unrepost(store: RootStoreModel, repostUri: string) { - const repostUrip = new AtUri(repostUri) - return await store.api.app.bsky.feed.repost.delete({ - did: repostUrip.hostname, - rkey: repostUrip.rkey, - }) -} - -export async function follow( - store: RootStoreModel, - subjectDid: string, - subjectDeclarationCid: string, -) { - return await store.api.app.bsky.graph.follow.create( - {did: store.me.did || ''}, - { - subject: { - did: subjectDid, - declarationCid: subjectDeclarationCid, - }, - createdAt: new Date().toISOString(), - }, - ) -} - -export async function unfollow(store: RootStoreModel, followUri: string) { - const followUrip = new AtUri(followUri) - return await store.api.app.bsky.graph.follow.delete({ - did: followUrip.hostname, - rkey: followUrip.rkey, - }) -} diff --git a/src/state/lib/bg-scheduler.ts b/src/state/lib/bg-scheduler.ts deleted file mode 100644 index 97ccb78b2..000000000 --- a/src/state/lib/bg-scheduler.ts +++ /dev/null @@ -1,18 +0,0 @@ -import BackgroundFetch, { - BackgroundFetchStatus, -} from 'react-native-background-fetch' - -export function configure( - handler: (taskId: string) => Promise, - timeoutHandler: (taskId: string) => Promise, -): Promise { - return BackgroundFetch.configure( - {minimumFetchInterval: 15}, - handler, - timeoutHandler, - ) -} - -export function finish(taskId: string) { - return BackgroundFetch.finish(taskId) -} diff --git a/src/state/lib/bg-scheduler.web.ts b/src/state/lib/bg-scheduler.web.ts deleted file mode 100644 index 91ec9428f..000000000 --- a/src/state/lib/bg-scheduler.web.ts +++ /dev/null @@ -1,13 +0,0 @@ -type BackgroundFetchStatus = 0 | 1 | 2 - -export async function configure( - _handler: (taskId: string) => Promise, - _timeoutHandler: (taskId: string) => Promise, -): Promise { - // TODO - return 0 -} - -export function finish(_taskId: string) { - // TODO -} diff --git a/src/state/lib/storage.ts b/src/state/lib/storage.ts deleted file mode 100644 index dc5fb620f..000000000 --- a/src/state/lib/storage.ts +++ /dev/null @@ -1,52 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' - -export async function loadString(key: string): Promise { - try { - return await AsyncStorage.getItem(key) - } catch { - // not sure why this would fail... even reading the RN docs I'm unclear - return null - } -} - -export async function saveString(key: string, value: string): Promise { - try { - await AsyncStorage.setItem(key, value) - return true - } catch { - return false - } -} - -export async function load(key: string): Promise { - try { - const str = await AsyncStorage.getItem(key) - if (typeof str !== 'string') { - return null - } - return JSON.parse(str) - } catch { - return null - } -} - -export async function save(key: string, value: any): Promise { - try { - await AsyncStorage.setItem(key, JSON.stringify(value)) - return true - } catch { - return false - } -} - -export async function remove(key: string): Promise { - try { - await AsyncStorage.removeItem(key) - } catch {} -} - -export async function clear(): Promise { - try { - await AsyncStorage.clear() - } catch {} -} diff --git a/src/state/lib/type-guards.ts b/src/state/lib/type-guards.ts deleted file mode 100644 index 8fe651ffb..000000000 --- a/src/state/lib/type-guards.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function isObj(v: unknown): v is Record { - return !!v && typeof v === 'object' -} - -export function hasProp( - data: object, - prop: K, -): data is Record { - return prop in data -} - -export function isStrArray(v: unknown): v is string[] { - return Array.isArray(v) && v.every(item => typeof item === 'string') -} diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts index 621059822..f80c5f2c0 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -5,13 +5,16 @@ import { AppBskyFeedPost, AppBskyFeedGetAuthorFeed as GetAuthorFeed, } from '@atproto/api' +import AwaitLock from 'await-lock' +import {bundleAsync} from 'lib/async/bundle' type FeedViewPost = AppBskyFeedFeedViewPost.Main type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost type PostView = AppBskyFeedPost.View import {AtUri} from '../../third-party/uri' import {RootStoreModel} from './root-store' -import * as apilib from '../lib/api' -import {cleanError} from '../../lib/strings' +import * as apilib from 'lib/api/index' +import {cleanError} from 'lib/strings/errors' +import {RichText} from 'lib/strings/rich-text' const PAGE_SIZE = 30 @@ -37,6 +40,7 @@ export class FeedItemModel { reply?: FeedViewPost['reply'] replyParent?: FeedItemModel reason?: FeedViewPost['reason'] + richText?: RichText constructor( public rootStore: RootStoreModel, @@ -49,6 +53,11 @@ export class FeedItemModel { const valid = AppBskyFeedPost.validateRecord(this.post.record) if (valid.success) { this.postRecord = this.post.record + this.richText = new RichText( + this.postRecord.text, + this.postRecord.entities, + {cleanNewlines: true}, + ) } else { rootStore.log.warn( 'Received an invalid app.bsky.feed.post record', @@ -187,10 +196,9 @@ export class FeedModel { hasMore = true loadMoreCursor: string | undefined pollCursor: string | undefined - _loadPromise: Promise | undefined - _loadMorePromise: Promise | undefined - _loadLatestPromise: Promise | undefined - _updatePromise: Promise | undefined + + // used to linearize async modifications to state + private lock = new AwaitLock() // data feed: FeedItemModel[] = [] @@ -206,10 +214,6 @@ export class FeedModel { rootStore: false, params: false, loadMoreCursor: false, - _loadPromise: false, - _loadMorePromise: false, - _loadLatestPromise: false, - _updatePromise: false, }, {autoBind: true}, ) @@ -229,13 +233,22 @@ export class FeedModel { } get nonReplyFeed() { - return this.feed.filter( - item => + const nonReplyFeed = this.feed.filter(item => { + const params = this.params as GetAuthorFeed.QueryParams + const isRepost = + item.reply && + (item?.reasonRepost?.by?.handle === params.author || + item?.reasonRepost?.by?.did === params.author) + + return ( !item.reply || // not a reply + isRepost || ((item._isThreadParent || // but allow if it's a thread by the user item._isThreadChild) && - item.reply?.root.author.did === item.post.author.did), - ) + item.reply?.root.author.did === item.post.author.did) + ) + }) + return nonReplyFeed } setHasNewLatest(v: boolean) { @@ -245,22 +258,45 @@ export class FeedModel { // public api // = + /** + * Nuke all data + */ + clear() { + this.rootStore.log.debug('FeedModel:clear') + this.isLoading = false + this.isRefreshing = false + this.hasNewLatest = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.pollCursor = undefined + this.feed = [] + } + /** * Load for first render */ - async setup(isRefreshing = false) { + setup = bundleAsync(async (isRefreshing: boolean = false) => { + this.rootStore.log.debug('FeedModel:setup', {isRefreshing}) if (isRefreshing) { this.isRefreshing = true // set optimistically for UI } - if (this._loadPromise) { - return this._loadPromise + await this.lock.acquireAsync() + try { + this.setHasNewLatest(false) + this._xLoading(isRefreshing) + try { + const res = await this._getFeed({limit: PAGE_SIZE}) + await this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + } finally { + this.lock.release() } - await this._pendingWork() - this.setHasNewLatest(false) - this._loadPromise = this._initialLoad(isRefreshing) - await this._loadPromise - this._loadPromise = undefined - } + }) /** * Register any event listeners. Returns a cleanup function. @@ -280,42 +316,93 @@ export class FeedModel { /** * Load more posts to the end of the feed */ - async loadMore() { - if (this._loadMorePromise) { - return this._loadMorePromise + loadMore = bundleAsync(async () => { + await this.lock.acquireAsync() + try { + if (!this.hasMore || this.hasError) { + return + } + this._xLoading() + try { + const res = await this._getFeed({ + before: this.loadMoreCursor, + limit: PAGE_SIZE, + }) + await this._appendAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('FeedView: Failed to load more', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() } - await this._pendingWork() - this._loadMorePromise = this._loadMore() - await this._loadMorePromise - this._loadMorePromise = undefined - } + }) /** * Load more posts to the start of the feed */ - async loadLatest() { - if (this._loadLatestPromise) { - return this._loadLatestPromise + loadLatest = bundleAsync(async () => { + await this.lock.acquireAsync() + try { + this.setHasNewLatest(false) + this._xLoading() + try { + const res = await this._getFeed({limit: PAGE_SIZE}) + await this._prependAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('FeedView: Failed to load latest', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() } - await this._pendingWork() - this.setHasNewLatest(false) - this._loadLatestPromise = this._loadLatest() - await this._loadLatestPromise - this._loadLatestPromise = undefined - } + }) /** * Update content in-place */ - async update() { - if (this._updatePromise) { - return this._updatePromise + update = bundleAsync(async () => { + await this.lock.acquireAsync() + try { + if (!this.feed.length) { + return + } + this._xLoading() + let numToFetch = this.feed.length + let cursor + try { + do { + const res: GetTimeline.Response = await this._getFeed({ + before: cursor, + limit: Math.min(numToFetch, 100), + }) + if (res.data.feed.length === 0) { + break // sanity check + } + this._updateAll(res) + numToFetch -= res.data.feed.length + cursor = res.data.cursor + } while (cursor && numToFetch > 0) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('FeedView: Failed to update', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() } - await this._pendingWork() - this._updatePromise = this._update() - await this._updatePromise - this._updatePromise = undefined - } + }) /** * Check if new posts are available @@ -324,17 +411,18 @@ export class FeedModel { if (this.hasNewLatest) { return } - await this._pendingWork() const res = await this._getFeed({limit: 1}) const currentLatestUri = this.pollCursor - const receivedLatestUri = res.data.feed[0] - ? res.data.feed[0].post.uri - : undefined - const hasNewLatest = Boolean( - receivedLatestUri && - (this.feed.length === 0 || receivedLatestUri !== currentLatestUri), - ) - this.setHasNewLatest(hasNewLatest) + const item = res.data.feed[0] + if (!item) { + return + } + if (AppBskyFeedFeedViewPost.isReasonRepost(item.reason)) { + if (item.reason.by.did === this.rootStore.me.did) { + return // ignore reposts by the user + } + } + this.setHasNewLatest(item.post.uri !== currentLatestUri) } /** @@ -363,95 +451,15 @@ export class FeedModel { this.isLoading = false this.isRefreshing = false this.hasLoaded = true - this.error = err ? cleanError(err.toString()) : '' + this.error = cleanError(err) if (err) { this.rootStore.log.error('Posts feed request failed', err) } } - // loader functions + // helper functions // = - private async _pendingWork() { - if (this._loadPromise) { - await this._loadPromise - } - if (this._loadMorePromise) { - await this._loadMorePromise - } - if (this._loadLatestPromise) { - await this._loadLatestPromise - } - if (this._updatePromise) { - await this._updatePromise - } - } - - private async _initialLoad(isRefreshing = false) { - this._xLoading(isRefreshing) - try { - const res = await this._getFeed({limit: PAGE_SIZE}) - await this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - private async _loadLatest() { - this._xLoading() - try { - const res = await this._getFeed({limit: PAGE_SIZE}) - await this._prependAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - private async _loadMore() { - if (!this.hasMore || this.hasError) { - return - } - this._xLoading() - try { - const res = await this._getFeed({ - before: this.loadMoreCursor, - limit: PAGE_SIZE, - }) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - private async _update() { - if (!this.feed.length) { - return - } - this._xLoading() - let numToFetch = this.feed.length - let cursor - try { - do { - const res: GetTimeline.Response = await this._getFeed({ - before: cursor, - limit: Math.min(numToFetch, 100), - }) - if (res.data.feed.length === 0) { - break // sanity check - } - this._updateAll(res) - numToFetch -= res.data.feed.length - cursor = res.data.cursor - } while (cursor && numToFetch > 0) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - private async _replaceAll( res: GetTimeline.Response | GetAuthorFeed.Response, ) { @@ -570,32 +578,9 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] { reorg.unshift(item) } - // phase two: identify the positions of the threads - let activeSlice = -1 - let threadSlices: Slice[] = [] - for (let i = 0; i < reorg.length; i++) { - const item = reorg[i] as FeedViewPostWithThreadMeta - if (activeSlice === -1) { - if (item._isThreadParent) { - activeSlice = i - } - } else { - if (!item._isThreadChild) { - threadSlices.push({index: activeSlice, length: i - activeSlice}) - if (item._isThreadParent) { - activeSlice = i - } else { - activeSlice = -1 - } - } - } - } - if (activeSlice !== -1) { - threadSlices.push({index: activeSlice, length: reorg.length - activeSlice}) - } - - // phase three: reorder the feed so that the timestamp of the + // phase two: reorder the feed so that the timestamp of the // last post in a thread establishes its ordering + let threadSlices: Slice[] = identifyThreadSlices(reorg) for (const slice of threadSlices) { const removed: FeedViewPostWithThreadMeta[] = reorg.splice( slice.index, @@ -610,8 +595,10 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] { slice.index = newIndex } - // phase four: compress any threads that are longer than 3 posts + // phase three: compress any threads that are longer than 3 posts let removedCount = 0 + // phase 2 moved posts around, so we need to re-identify the slice indices + threadSlices = identifyThreadSlices(reorg) for (const slice of threadSlices) { if (slice.length > 3) { reorg.splice(slice.index - removedCount + 1, slice.length - 3) @@ -626,6 +613,32 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] { return reorg } +function identifyThreadSlices(feed: FeedViewPost[]): Slice[] { + let activeSlice = -1 + let threadSlices: Slice[] = [] + for (let i = 0; i < feed.length; i++) { + const item = feed[i] as FeedViewPostWithThreadMeta + if (activeSlice === -1) { + if (item._isThreadParent) { + activeSlice = i + } + } else { + if (!item._isThreadChild) { + threadSlices.push({index: activeSlice, length: i - activeSlice}) + if (item._isThreadParent) { + activeSlice = i + } else { + activeSlice = -1 + } + } + } + } + if (activeSlice !== -1) { + threadSlices.push({index: activeSlice, length: feed.length - activeSlice}) + } + return threadSlices +} + // WARNING: mutates `feed` function dedupReposts(feed: FeedItemModel[]) { // remove duplicates caused by reposts diff --git a/src/state/models/get-assertions-view.ts b/src/state/models/get-assertions-view.ts deleted file mode 100644 index bdb2c0894..000000000 --- a/src/state/models/get-assertions-view.ts +++ /dev/null @@ -1,123 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {AppBskyGraphGetAssertions as GetAssertions} from '@atproto/api' -import {RootStoreModel} from './root-store' - -export type Assertion = GetAssertions.Assertion & { - _reactKey: string -} - -export class GetAssertionsView { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - params: GetAssertions.QueryParams - - // data - assertions: Assertion[] = [] - - constructor( - public rootStore: RootStoreModel, - params: GetAssertions.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.assertions.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - getBySubject(did: string) { - return this.assertions.find(assertion => assertion.subject.did === did) - } - - get confirmed() { - return this.assertions.filter(assertion => !!assertion.confirmation) - } - - get unconfirmed() { - return this.assertions.filter(assertion => !assertion.confirmation) - } - - // public api - // = - - async setup() { - await this._fetch() - } - - async refresh() { - await this._fetch(true) - } - - async loadMore() { - // TODO - } - - // state transitions - // = - - private _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - private _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = err ? err.toString() : '' - if (err) { - this.rootStore.log.error('Failed to fetch assertions', err) - } - } - - // loader functions - // = - - private async _fetch(isRefreshing = false) { - this._xLoading(isRefreshing) - try { - const res = await this.rootStore.api.app.bsky.graph.getAssertions( - this.params, - ) - this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - private _replaceAll(res: GetAssertions.Response) { - this.assertions.length = 0 - let counter = 0 - for (const item of res.data.assertions) { - this._append({ - _reactKey: `item-${counter++}`, - ...item, - }) - } - } - - private _append(item: Assertion) { - this.assertions.push(item) - } -} diff --git a/src/state/models/link-metas-view.ts b/src/state/models/link-metas-view.ts index 6b787987d..59447008a 100644 --- a/src/state/models/link-metas-view.ts +++ b/src/state/models/link-metas-view.ts @@ -1,7 +1,7 @@ import {makeAutoObservable} from 'mobx' import {LRUMap} from 'lru_map' import {RootStoreModel} from './root-store' -import {LinkMeta, getLinkMeta} from '../../lib/link-meta' +import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta' type CacheValue = Promise | LinkMeta export class LinkMetasViewModel { diff --git a/src/state/models/log.ts b/src/state/models/log.ts index 67f4a210c..f2709f2f1 100644 --- a/src/state/models/log.ts +++ b/src/state/models/log.ts @@ -1,6 +1,6 @@ import {makeAutoObservable} from 'mobx' import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' -import {isObj, hasProp} from '../lib/type-guards' +import {isObj, hasProp} from 'lib/type-guards' interface LogEntry { id: string diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 0d0c1d1de..0cb84c9fc 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -1,10 +1,9 @@ import {makeAutoObservable, runInAction} from 'mobx' -import notifee from '@notifee/react-native' import {RootStoreModel} from './root-store' import {FeedModel} from './feed-view' import {NotificationsViewModel} from './notifications-view' -import {isObj, hasProp} from '../lib/type-guards' -import {displayNotificationFromModel} from '../../view/lib/notifee' +import {MyFollowsModel} from './my-follows' +import {isObj, hasProp} from 'lib/type-guards' export class MeModel { did: string = '' @@ -12,9 +11,9 @@ export class MeModel { displayName: string = '' description: string = '' avatar: string = '' - notificationCount: number = 0 mainFeed: FeedModel notifications: NotificationsViewModel + follows: MyFollowsModel constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -26,15 +25,17 @@ export class MeModel { algorithm: 'reverse-chronological', }) this.notifications = new NotificationsViewModel(this.rootStore, {}) + this.follows = new MyFollowsModel(this.rootStore) } clear() { + this.mainFeed.clear() + this.notifications.clear() this.did = '' this.handle = '' this.displayName = '' this.description = '' this.avatar = '' - this.notificationCount = 0 } serialize(): unknown { @@ -77,9 +78,10 @@ export class MeModel { async load() { const sess = this.rootStore.session - if (sess.hasSession && sess.data) { - this.did = sess.data.did || '' - this.handle = sess.data.handle + this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession}) + if (sess.hasSession) { + this.did = sess.currentSession?.did || '' + this.handle = sess.currentSession?.handle || '' const profile = await this.rootStore.api.app.bsky.actor.getProfile({ actor: this.did, }) @@ -94,10 +96,6 @@ export class MeModel { this.avatar = '' } }) - this.mainFeed = new FeedModel(this.rootStore, 'home', { - algorithm: 'reverse-chronological', - }) - this.notifications = new NotificationsViewModel(this.rootStore, {}) await Promise.all([ this.mainFeed.setup().catch(e => { this.rootStore.log.error('Failed to setup main feed model', e) @@ -105,51 +103,13 @@ export class MeModel { this.notifications.setup().catch(e => { this.rootStore.log.error('Failed to setup notifications model', e) }), + this.follows.fetch().catch(e => { + this.rootStore.log.error('Failed to load my follows', e) + }), ]) - - // request notifications permission once the user has logged in - notifee.requestPermission() + this.rootStore.emitSessionLoaded() } else { this.clear() } } - - clearNotificationCount() { - this.notificationCount = 0 - notifee.setBadgeCount(0) - } - - async fetchNotifications() { - const res = await this.rootStore.api.app.bsky.notification.getCount() - runInAction(() => { - const newNotifications = this.notificationCount !== res.data.count - this.notificationCount = res.data.count - notifee.setBadgeCount(this.notificationCount) - if (newNotifications) { - this.notifications.refresh() - } - }) - } - - async bgFetchNotifications() { - const res = await this.rootStore.api.app.bsky.notification.getCount() - // NOTE we don't update this.notificationCount to avoid repaints during bg - // this means `newNotifications` may not be accurate, so we rely on - // `mostRecent` to determine if there really is a new notif to show -prf - const newNotifications = this.notificationCount !== res.data.count - notifee.setBadgeCount(res.data.count) - this.rootStore.log.debug( - `Background fetch received unread count = ${res.data.count}`, - ) - if (newNotifications) { - this.rootStore.log.debug( - 'Background fetch detected potentially a new notification', - ) - const mostRecent = await this.notifications.getNewMostRecent() - if (mostRecent) { - this.rootStore.log.debug('Got the notification, triggering a push') - displayNotificationFromModel(mostRecent) - } - } - } } diff --git a/src/state/models/my-follows.ts b/src/state/models/my-follows.ts new file mode 100644 index 000000000..252e8a3d3 --- /dev/null +++ b/src/state/models/my-follows.ts @@ -0,0 +1,109 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' +import {RootStoreModel} from './root-store' +import {bundleAsync} from 'lib/async/bundle' + +const CACHE_TTL = 1000 * 60 * 60 // hourly +type FollowsListResponse = Awaited> +type FollowsListResponseRecord = FollowsListResponse['records'][0] +type Profile = + | AppBskyActorProfile.ViewBasic + | AppBskyActorProfile.View + | AppBskyActorRef.WithInfo + +/** + * This model is used to maintain a synced local cache of the user's + * follows. It should be periodically refreshed and updated any time + * the user makes a change to their follows. + */ +export class MyFollowsModel { + // data + followDidToRecordMap: Record = {} + lastSync = 0 + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + // public api + // = + + fetchIfNeeded = bundleAsync(async () => { + if ( + Object.keys(this.followDidToRecordMap).length === 0 || + Date.now() - this.lastSync > CACHE_TTL + ) { + return await this.fetch() + } + }) + + fetch = bundleAsync(async () => { + this.rootStore.log.debug('MyFollowsModel:fetch running full fetch') + let before + let records: FollowsListResponseRecord[] = [] + do { + const res: FollowsListResponse = + await this.rootStore.api.app.bsky.graph.follow.list({ + user: this.rootStore.me.did, + before, + }) + records = records.concat(res.records) + before = res.cursor + } while (typeof before !== 'undefined') + runInAction(() => { + this.followDidToRecordMap = {} + for (const record of records) { + this.followDidToRecordMap[record.value.subject.did] = record.uri + } + this.lastSync = Date.now() + }) + }) + + isFollowing(did: string) { + return !!this.followDidToRecordMap[did] + } + + getFollowUri(did: string): string { + const v = this.followDidToRecordMap[did] + if (!v) { + throw new Error('Not a followed user') + } + return v + } + + addFollow(did: string, recordUri: string) { + this.followDidToRecordMap[did] = recordUri + } + + removeFollow(did: string) { + delete this.followDidToRecordMap[did] + } + + /** + * Use this to incrementally update the cache as views provide information + */ + hydrate(did: string, recordUri: string | undefined) { + if (recordUri) { + this.followDidToRecordMap[did] = recordUri + } else { + delete this.followDidToRecordMap[did] + } + } + + /** + * Use this to incrementally update the cache as views provide information + */ + hydrateProfiles(profiles: Profile[]) { + for (const profile of profiles) { + if (profile.viewer) { + this.hydrate(profile.did, profile.viewer.following) + } + } + } +} diff --git a/src/state/models/navigation.ts b/src/state/models/navigation.ts index 224ffef0d..feb03b367 100644 --- a/src/state/models/navigation.ts +++ b/src/state/models/navigation.ts @@ -1,5 +1,7 @@ +import {RootStoreModel} from './root-store' import {makeAutoObservable} from 'mobx' -import {TABS_ENABLED} from '../../build-flags' +import {TABS_ENABLED} from 'lib/build-flags' +import {segmentClient} from 'lib/analytics' let __id = 0 function genId() { @@ -11,13 +13,20 @@ function genId() { // we've since decided to pause that idea and do something more traditional // until we're fully sure what that is, the tabs are being repurposed into a fixed topology // - Tab 0: The "Default" tab -// - Tab 1: The "Notifications" tab +// - Tab 1: The "Search" tab +// - Tab 2: The "Notifications" tab // These tabs always retain the first item in their history. -// The default tab is used for basically everything except notifications. // -prf export enum TabPurpose { Default = 0, - Notifs = 1, + Search = 1, + Notifs = 2, +} + +export const TabPurposeMainPath: Record = { + [TabPurpose.Default]: '/', + [TabPurpose.Search]: '/search', + [TabPurpose.Notifs]: '/notifications', } interface HistoryItem { @@ -36,11 +45,9 @@ export class NavigationTabModel { isNewTab = false constructor(public fixedTabPurpose: TabPurpose) { - if (fixedTabPurpose === TabPurpose.Notifs) { - this.history = [{url: '/notifications', ts: Date.now(), id: genId()}] - } else { - this.history = [{url: '/', ts: Date.now(), id: genId()}] - } + this.history = [ + {url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()}, + ] makeAutoObservable(this, { serialize: false, hydrate: false, @@ -96,6 +103,13 @@ export class NavigationTabModel { // = navigate(url: string, title?: string) { + try { + const path = url.split('/')[1] + segmentClient.track('Navigation', { + path, + }) + } catch (error) {} + if (this.current?.url === url) { this.refresh() } else { @@ -104,8 +118,7 @@ export class NavigationTabModel { } // TEMP ensure the tab has its purpose's main view -prf if (this.history.length < 1) { - const fixedUrl = - this.fixedTabPurpose === TabPurpose.Notifs ? '/notifications' : '/' + const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose] this.history.push({url: fixedUrl, ts: Date.now(), id: genId()}) } this.history.push({url, title, ts: Date.now(), id: genId()}) @@ -211,12 +224,14 @@ export class NavigationTabModel { export class NavigationModel { tabs: NavigationTabModel[] = [ new NavigationTabModel(TabPurpose.Default), + new NavigationTabModel(TabPurpose.Search), new NavigationTabModel(TabPurpose.Notifs), ] tabIndex = 0 - constructor() { + constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, { + rootStore: false, serialize: false, hydrate: false, }) @@ -225,6 +240,7 @@ export class NavigationModel { clear() { this.tabs = [ new NavigationTabModel(TabPurpose.Default), + new NavigationTabModel(TabPurpose.Search), new NavigationTabModel(TabPurpose.Notifs), ] this.tabIndex = 0 @@ -249,6 +265,7 @@ export class NavigationModel { // = navigate(url: string, title?: string) { + this.rootStore.emitNavigation() this.tab.navigate(url, title) } @@ -286,10 +303,16 @@ export class NavigationModel { // fixed tab helper function // -prf switchTo(purpose: TabPurpose, reset: boolean) { - if (purpose === TabPurpose.Notifs) { - this.tabIndex = 1 - } else { - this.tabIndex = 0 + this.rootStore.emitNavigation() + switch (purpose) { + case TabPurpose.Notifs: + this.tabIndex = 2 + break + case TabPurpose.Search: + this.tabIndex = 1 + break + default: + this.tabIndex = 0 } if (reset) { this.tab.fixedTabReset() diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts index 93b6a398f..048de968d 100644 --- a/src/state/models/notifications-view.ts +++ b/src/state/models/notifications-view.ts @@ -8,11 +8,13 @@ import { AppBskyGraphAssertion, AppBskyGraphFollow, } from '@atproto/api' +import AwaitLock from 'await-lock' +import {bundleAsync} from 'lib/async/bundle' import {RootStoreModel} from './root-store' import {PostThreadViewModel} from './post-thread-view' -import {cleanError} from '../../lib/strings' +import {cleanError} from 'lib/strings/errors' -const UNGROUPABLE_REASONS = ['assertion'] +const GROUPABLE_REASONS = ['vote', 'repost', 'follow'] const PAGE_SIZE = 30 const MS_1HR = 1e3 * 60 * 60 const MS_2DAY = MS_1HR * 48 @@ -190,15 +192,16 @@ export class NotificationsViewModel { params: ListNotifications.QueryParams hasMore = true loadMoreCursor?: string - _loadPromise: Promise | undefined - _loadMorePromise: Promise | undefined - _updatePromise: Promise | undefined + + // used to linearize async modifications to state + private lock = new AwaitLock() // data notifications: NotificationsViewItemModel[] = [] + unreadCount = 0 // this is used to help trigger push notifications - mostRecentNotification: NotificationsViewItemModel | undefined + mostRecentNotificationUri: string | undefined constructor( public rootStore: RootStoreModel, @@ -209,10 +212,7 @@ export class NotificationsViewModel { { rootStore: false, params: false, - mostRecentNotification: false, - _loadPromise: false, - _loadMorePromise: false, - _updatePromise: false, + mostRecentNotificationUri: false, }, {autoBind: true}, ) @@ -234,21 +234,48 @@ export class NotificationsViewModel { // public api // = + /** + * Nuke all data + */ + clear() { + this.rootStore.log.debug('NotificationsModel:clear') + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.notifications = [] + this.unreadCount = 0 + this.rootStore.emitUnreadNotifications(0) + this.mostRecentNotificationUri = undefined + } + /** * Load for first render */ - async setup(isRefreshing = false) { + setup = bundleAsync(async (isRefreshing: boolean = false) => { + this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing}) if (isRefreshing) { this.isRefreshing = true // set optimistically for UI } - if (this._loadPromise) { - return this._loadPromise + await this.lock.acquireAsync() + try { + this._xLoading(isRefreshing) + try { + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + }) + const res = await this.rootStore.api.app.bsky.notification.list(params) + await this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + } finally { + this.lock.release() } - await this._pendingWork() - this._loadPromise = this._initialLoad(isRefreshing) - await this._loadPromise - this._loadPromise = undefined - } + }) /** * Reset and load @@ -260,59 +287,148 @@ export class NotificationsViewModel { /** * Load more posts to the end of the notifications */ - async loadMore() { - if (this._loadMorePromise) { - return this._loadMorePromise + loadMore = bundleAsync(async () => { + if (!this.hasMore) { + return } - await this._pendingWork() - this._loadMorePromise = this._loadMore() - await this._loadMorePromise - this._loadMorePromise = undefined - } + this.lock.acquireAsync() + try { + this._xLoading() + try { + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + before: this.loadMoreCursor, + }) + const res = await this.rootStore.api.app.bsky.notification.list(params) + await this._appendAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('NotificationsView: Failed to load more', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() + } + }) + + /** + * Load more posts at the start of the notifications + */ + loadLatest = bundleAsync(async () => { + if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) { + return this.refresh() + } + this.lock.acquireAsync() + try { + this._xLoading() + try { + const res = await this.rootStore.api.app.bsky.notification.list({ + limit: PAGE_SIZE, + }) + await this._prependAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('NotificationsView: Failed to load latest', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() + } + }) /** * Update content in-place */ - async update() { - if (this._updatePromise) { - return this._updatePromise + update = bundleAsync(async () => { + await this.lock.acquireAsync() + try { + if (!this.notifications.length) { + return + } + this._xLoading() + let numToFetch = this.notifications.length + let cursor + try { + do { + const res: ListNotifications.Response = + await this.rootStore.api.app.bsky.notification.list({ + before: cursor, + limit: Math.min(numToFetch, 100), + }) + if (res.data.notifications.length === 0) { + break // sanity check + } + this._updateAll(res) + numToFetch -= res.data.notifications.length + cursor = res.data.cursor + } while (cursor && numToFetch > 0) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('NotificationsView: Failed to update', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() } - await this._pendingWork() - this._updatePromise = this._update() - await this._updatePromise - this._updatePromise = undefined - } + }) + + // unread notification apis + // = + + /** + * Get the current number of unread notifications + * returns true if the number changed + */ + loadUnreadCount = bundleAsync(async () => { + const old = this.unreadCount + const res = await this.rootStore.api.app.bsky.notification.getCount() + runInAction(() => { + this.unreadCount = res.data.count + }) + this.rootStore.emitUnreadNotifications(this.unreadCount) + return this.unreadCount !== old + }) /** * Update read/unread state */ - async updateReadState() { + async markAllRead() { try { + this.unreadCount = 0 + this.rootStore.emitUnreadNotifications(0) await this.rootStore.api.app.bsky.notification.updateSeen({ seenAt: new Date().toISOString(), }) - this.rootStore.me.clearNotificationCount() } catch (e: any) { this.rootStore.log.warn('Failed to update notifications read state', e) } } async getNewMostRecent(): Promise { - let old = this.mostRecentNotification - const res = await this.rootStore.api.app.bsky.notification.list({limit: 1}) - if ( - !res.data.notifications[0] || - old?.uri === res.data.notifications[0].uri - ) { + let old = this.mostRecentNotificationUri + const res = await this.rootStore.api.app.bsky.notification.list({ + limit: 1, + }) + if (!res.data.notifications[0] || old === res.data.notifications[0].uri) { return } - this.mostRecentNotification = new NotificationsViewItemModel( + this.mostRecentNotificationUri = res.data.notifications[0].uri + const notif = new NotificationsViewItemModel( this.rootStore, 'mostRecent', res.data.notifications[0], ) - await this.mostRecentNotification.fetchAdditionalData() - return this.mostRecentNotification + await notif.fetchAdditionalData() + return notif } // state transitions @@ -329,93 +445,17 @@ export class NotificationsViewModel { this.isRefreshing = false this.hasLoaded = true this.error = cleanError(err) - this.error = err ? cleanError(err) : '' if (err) { this.rootStore.log.error('Failed to fetch notifications', err) } } - // loader functions + // helper functions // = - private async _pendingWork() { - if (this._loadPromise) { - await this._loadPromise - } - if (this._loadMorePromise) { - await this._loadMorePromise - } - if (this._updatePromise) { - await this._updatePromise - } - } - - private async _initialLoad(isRefreshing = false) { - this._xLoading(isRefreshing) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - }) - const res = await this.rootStore.api.app.bsky.notification.list(params) - await this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - private async _loadMore() { - if (!this.hasMore) { - return - } - this._xLoading() - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - before: this.loadMoreCursor, - }) - const res = await this.rootStore.api.app.bsky.notification.list(params) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - private async _update() { - if (!this.notifications.length) { - return - } - this._xLoading() - let numToFetch = this.notifications.length - let cursor - try { - do { - const res: ListNotifications.Response = - await this.rootStore.api.app.bsky.notification.list({ - before: cursor, - limit: Math.min(numToFetch, 100), - }) - if (res.data.notifications.length === 0) { - break // sanity check - } - this._updateAll(res) - numToFetch -= res.data.notifications.length - cursor = res.data.cursor - } while (cursor && numToFetch > 0) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - private async _replaceAll(res: ListNotifications.Response) { if (res.data.notifications[0]) { - this.mostRecentNotification = new NotificationsViewItemModel( - this.rootStore, - 'mostRecent', - res.data.notifications[0], - ) + this.mostRecentNotificationUri = res.data.notifications[0].uri } return this._appendAll(res, true) } @@ -451,14 +491,40 @@ export class NotificationsViewModel { }) } + private async _prependAll(res: ListNotifications.Response) { + const promises = [] + const itemModels: NotificationsViewItemModel[] = [] + const dedupedNotifs = res.data.notifications.filter( + n1 => + !this.notifications.find( + n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)), + ), + ) + for (const item of groupNotifications(dedupedNotifs)) { + const itemModel = new NotificationsViewItemModel( + this.rootStore, + `item-${_idCounter++}`, + item, + ) + if (itemModel.needsAdditionalData) { + promises.push(itemModel.fetchAdditionalData()) + } + itemModels.push(itemModel) + } + await Promise.all(promises).catch(e => { + this.rootStore.log.error( + 'Uncaught failure during notifications-view _prependAll()', + e, + ) + }) + runInAction(() => { + this.notifications = itemModels.concat(this.notifications) + }) + } + private _updateAll(res: ListNotifications.Response) { for (const item of res.data.notifications) { - const existingItem = this.notifications.find( - // this find function has a key subtlety- the indexedAt comparison - // the reason for this is reposts: they set the URI of the original post, not of the repost record - // the indexedAt time will be for the repost however, so we use that to help us - item2 => item.uri === item2.uri && item.indexedAt === item2.indexedAt, - ) + const existingItem = this.notifications.find(item2 => isEq(item, item2)) if (existingItem) { existingItem.copy(item, true) } @@ -473,7 +539,7 @@ function groupNotifications( for (const item of items) { const ts = +new Date(item.indexedAt) let grouped = false - if (!UNGROUPABLE_REASONS.includes(item.reason)) { + if (GROUPABLE_REASONS.includes(item.reason)) { for (const item2 of items2) { const ts2 = +new Date(item2.indexedAt) if ( @@ -495,3 +561,11 @@ function groupNotifications( } return items2 } + +type N = ListNotifications.Notification | NotificationsViewItemModel +function isEq(a: N, b: N) { + // this function has a key subtlety- the indexedAt comparison + // the reason for this is reposts: they set the URI of the original post, not of the repost record + // the indexedAt time will be for the repost however, so we use that to help us + return a.uri === b.uri && a.indexedAt === b.indexedAt +} diff --git a/src/state/models/onboard.ts b/src/state/models/onboard.ts index 5ab5ecb62..aa275c6b7 100644 --- a/src/state/models/onboard.ts +++ b/src/state/models/onboard.ts @@ -1,5 +1,5 @@ import {makeAutoObservable} from 'mobx' -import {isObj, hasProp} from '../lib/type-guards' +import {isObj, hasProp} from 'lib/type-guards' export const OnboardStage = { Explainers: 'explainers', diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts index 584658e14..ad989cc53 100644 --- a/src/state/models/post-thread-view.ts +++ b/src/state/models/post-thread-view.ts @@ -5,7 +5,9 @@ import { } from '@atproto/api' import {AtUri} from '../../third-party/uri' import {RootStoreModel} from './root-store' -import * as apilib from '../lib/api' +import * as apilib from 'lib/api/index' +import {cleanError} from 'lib/strings/errors' +import {RichText} from 'lib/strings/rich-text' function* reactKeyGenerator(): Generator { let counter = 0 @@ -26,6 +28,7 @@ export class PostThreadViewPostModel { postRecord?: FeedPost.Record parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[] + richText?: RichText constructor( public rootStore: RootStoreModel, @@ -38,6 +41,11 @@ export class PostThreadViewPostModel { const valid = FeedPost.validateRecord(this.post.record) if (valid.success) { this.postRecord = this.post.record + this.richText = new RichText( + this.postRecord.text, + this.postRecord.entities, + {cleanNewlines: true}, + ) } else { rootStore.log.warn( 'Received an invalid app.bsky.feed.post record', @@ -276,7 +284,7 @@ export class PostThreadViewModel { this.isLoading = false this.isRefreshing = false this.hasLoaded = true - this.error = err ? err.toString() : '' + this.error = cleanError(err) if (err) { this.rootStore.log.error('Failed to fetch post thread', err) } @@ -290,7 +298,7 @@ export class PostThreadViewModel { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { - urip.host = await this.rootStore.resolveName(urip.host) + urip.host = await apilib.resolveName(this.rootStore, urip.host) } catch (e: any) { this.error = e.toString() } @@ -314,7 +322,7 @@ export class PostThreadViewModel { } private _replaceAll(res: GetPostThread.Response) { - // sortThread(res.data.thread) TODO needed? + sortThread(res.data.thread) const keyGen = reactKeyGenerator() const thread = new PostThreadViewPostModel( this.rootStore, @@ -330,36 +338,37 @@ export class PostThreadViewModel { } } -/* -TODO needed? +type MaybePost = + | GetPostThread.ThreadViewPost + | GetPostThread.NotFoundPost + | {[k: string]: unknown; $type: string} function sortThread(post: MaybePost) { if (post.notFound) { return } - post = post as GetPostThread.Post + post = post as GetPostThread.ThreadViewPost if (post.replies) { post.replies.sort((a: MaybePost, b: MaybePost) => { - post = post as GetPostThread.Post + post = post as GetPostThread.ThreadViewPost if (a.notFound) { return 1 } if (b.notFound) { return -1 } - a = a as GetPostThread.Post - b = b as GetPostThread.Post - const aIsByOp = a.author.did === post.author.did - const bIsByOp = b.author.did === post.author.did + a = a as GetPostThread.ThreadViewPost + b = b as GetPostThread.ThreadViewPost + const aIsByOp = a.post.author.did === post.post.author.did + const bIsByOp = b.post.author.did === post.post.author.did if (aIsByOp && bIsByOp) { - return a.indexedAt.localeCompare(b.indexedAt) // oldest + return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest } else if (aIsByOp) { return -1 // op's own reply } else if (bIsByOp) { return 1 // op's own reply } - return b.indexedAt.localeCompare(a.indexedAt) // newest + return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest }) post.replies.forEach(reply => sortThread(reply)) } } -*/ diff --git a/src/state/models/post.ts b/src/state/models/post.ts index 497c8e4c9..749e98bb0 100644 --- a/src/state/models/post.ts +++ b/src/state/models/post.ts @@ -2,7 +2,7 @@ import {makeAutoObservable} from 'mobx' import {AppBskyFeedPost as Post} from '@atproto/api' import {AtUri} from '../../third-party/uri' import {RootStoreModel} from './root-store' -import {cleanError} from '../../lib/strings' +import {cleanError} from 'lib/strings/errors' type RemoveIndex = { [P in keyof T as string extends P @@ -67,7 +67,6 @@ export class PostModel implements RemoveIndex { this.isLoading = false this.hasLoaded = true this.error = cleanError(err) - this.error = err ? cleanError(err) : '' if (err) { this.rootStore.log.error('Failed to fetch post', err) } diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts index 79882a562..8630eae52 100644 --- a/src/state/models/profile-view.ts +++ b/src/state/models/profile-view.ts @@ -1,22 +1,23 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {Image as PickedImage} from '../../view/com/util/images/image-crop-picker/ImageCropPicker' +import {PickedMedia} from 'view/com/util/images/image-crop-picker/ImageCropPicker' import { AppBskyActorGetProfile as GetProfile, AppBskyActorProfile as Profile, AppBskySystemDeclRef, - AppBskyFeedPost, } from '@atproto/api' type DeclRef = AppBskySystemDeclRef.Main -type Entity = AppBskyFeedPost.Entity -import {extractEntities} from '../../lib/strings' +import {extractEntities} from 'lib/strings/rich-text-detection' import {RootStoreModel} from './root-store' -import * as apilib from '../lib/api' +import * as apilib from 'lib/api/index' +import {cleanError} from 'lib/strings/errors' +import {RichText} from 'lib/strings/rich-text' export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' -export class ProfileViewMyStateModel { - follow?: string +export class ProfileViewViewerModel { muted?: boolean + following?: string + followedBy?: string constructor() { makeAutoObservable(this) @@ -46,10 +47,10 @@ export class ProfileViewModel { followersCount: number = 0 followsCount: number = 0 postsCount: number = 0 - myState = new ProfileViewMyStateModel() + viewer = new ProfileViewViewerModel() // added data - descriptionEntities?: Entity[] + descriptionRichText?: RichText constructor( public rootStore: RootStoreModel, @@ -97,11 +98,24 @@ export class ProfileViewModel { if (!this.rootStore.me.did) { throw new Error('Not logged in') } - if (this.myState.follow) { - await apilib.unfollow(this.rootStore, this.myState.follow) + + const follows = this.rootStore.me.follows + const followUri = follows.isFollowing(this.did) + ? follows.getFollowUri(this.did) + : undefined + + // guard against this view getting out of sync with the follows cache + if (followUri !== this.viewer.following) { + this.viewer.following = followUri + return + } + + if (followUri) { + await apilib.unfollow(this.rootStore, followUri) runInAction(() => { this.followersCount-- - this.myState.follow = undefined + this.viewer.following = undefined + this.rootStore.me.follows.removeFollow(this.did) }) } else { const res = await apilib.follow( @@ -111,15 +125,16 @@ export class ProfileViewModel { ) runInAction(() => { this.followersCount++ - this.myState.follow = res.uri + this.viewer.following = res.uri + this.rootStore.me.follows.addFollow(this.did, res.uri) }) } } async updateProfile( updates: Profile.Record, - newUserAvatar: PickedImage | undefined, - newUserBanner: PickedImage | undefined, + newUserAvatar: PickedMedia | undefined, + newUserBanner: PickedMedia | undefined, ) { if (newUserAvatar) { const res = await this.rootStore.api.com.atproto.blob.upload( @@ -152,13 +167,13 @@ export class ProfileViewModel { async muteAccount() { await this.rootStore.api.app.bsky.graph.mute({user: this.did}) - this.myState.muted = true + this.viewer.muted = true await this.refresh() } async unmuteAccount() { await this.rootStore.api.app.bsky.graph.unmute({user: this.did}) - this.myState.muted = false + this.viewer.muted = false await this.refresh() } @@ -175,7 +190,7 @@ export class ProfileViewModel { this.isLoading = false this.isRefreshing = false this.hasLoaded = true - this.error = err ? err.toString() : '' + this.error = cleanError(err) if (err) { this.rootStore.log.error('Failed to fetch profile', err) } @@ -210,9 +225,14 @@ export class ProfileViewModel { this.followersCount = res.data.followersCount this.followsCount = res.data.followsCount this.postsCount = res.data.postsCount - if (res.data.myState) { - Object.assign(this.myState, res.data.myState) + if (res.data.viewer) { + Object.assign(this.viewer, res.data.viewer) + this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following) } - this.descriptionEntities = extractEntities(this.description || '') + this.descriptionRichText = new RichText( + this.description || '', + extractEntities(this.description || ''), + {cleanNewlines: true}, + ) } } diff --git a/src/state/models/profiles-view.ts b/src/state/models/profiles-view.ts index 804491c8b..4241e50e1 100644 --- a/src/state/models/profiles-view.ts +++ b/src/state/models/profiles-view.ts @@ -31,7 +31,9 @@ export class ProfilesViewModel { } } try { - const promise = this.rootStore.api.app.bsky.actor.getProfile({actor: did}) + const promise = this.rootStore.api.app.bsky.actor.getProfile({ + actor: did, + }) this.cache.set(did, promise) const res = await promise this.cache.set(did, res) diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts index 1de6b7c58..69a728d6f 100644 --- a/src/state/models/reposted-by-view.ts +++ b/src/state/models/reposted-by-view.ts @@ -1,11 +1,17 @@ import {makeAutoObservable, runInAction} from 'mobx' import {AtUri} from '../../third-party/uri' -import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api' +import { + AppBskyFeedGetRepostedBy as GetRepostedBy, + AppBskyActorRef as ActorRef, +} from '@atproto/api' import {RootStoreModel} from './root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import * as apilib from 'lib/api/index' const PAGE_SIZE = 30 -export type RepostedByItem = GetRepostedBy.RepostedBy +export type RepostedByItem = ActorRef.WithInfo export class RepostedByViewModel { // state @@ -17,7 +23,6 @@ export class RepostedByViewModel { params: GetRepostedBy.QueryParams hasMore = true loadMoreCursor?: string - private _loadMorePromise: Promise | undefined // data uri: string = '' @@ -57,17 +62,28 @@ export class RepostedByViewModel { return this.loadMore(true) } - async loadMore(isRefreshing = false) { - if (this._loadMorePromise) { - return this._loadMorePromise - } - if (!this.resolvedUri) { - await this._resolveUri() + loadMore = bundleAsync(async (replace: boolean = false) => { + this._xLoading(replace) + try { + if (!this.resolvedUri) { + await this._resolveUri() + } + const params = Object.assign({}, this.params, { + uri: this.resolvedUri, + limit: PAGE_SIZE, + before: replace ? undefined : this.loadMoreCursor, + }) + const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) } - this._loadMorePromise = this._loadMore(isRefreshing) - await this._loadMorePromise - this._loadMorePromise = undefined - } + }) // state transitions // = @@ -82,20 +98,20 @@ export class RepostedByViewModel { this.isLoading = false this.isRefreshing = false this.hasLoaded = true - this.error = err ? err.toString() : '' + this.error = cleanError(err) if (err) { this.rootStore.log.error('Failed to fetch reposted by view', err) } } - // loader functions + // helper functions // = private async _resolveUri() { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { - urip.host = await this.rootStore.resolveName(urip.host) + urip.host = await apilib.resolveName(this.rootStore, urip.host) } catch (e: any) { this.error = e.toString() } @@ -105,28 +121,15 @@ export class RepostedByViewModel { }) } - private async _loadMore(isRefreshing = false) { - this._xLoading(isRefreshing) - try { - const params = Object.assign({}, this.params, { - uri: this.resolvedUri, - limit: PAGE_SIZE, - before: this.loadMoreCursor, - }) - if (this.isRefreshing) { - this.repostedBy = [] - } - const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } + private _replaceAll(res: GetRepostedBy.Response) { + this.repostedBy = [] + this._appendAll(res) } private _appendAll(res: GetRepostedBy.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.repostedBy = this.repostedBy.concat(res.data.repostedBy) + this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy) } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 2f6931cdc..11d496351 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -3,71 +3,63 @@ */ import {makeAutoObservable} from 'mobx' -import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api' +import {AtpAgent} from '@atproto/api' import {createContext, useContext} from 'react' import {DeviceEventEmitter, EmitterSubscription} from 'react-native' -import * as BgScheduler from '../lib/bg-scheduler' -import {isObj, hasProp} from '../lib/type-guards' +import * as BgScheduler from 'lib/bg-scheduler' +import {z} from 'zod' +import {isObj, hasProp} from 'lib/type-guards' import {LogModel} from './log' import {SessionModel} from './session' import {NavigationModel} from './navigation' import {ShellUiModel} from './shell-ui' import {ProfilesViewModel} from './profiles-view' import {LinkMetasViewModel} from './link-metas-view' +import {NotificationsViewItemModel} from './notifications-view' import {MeModel} from './me' import {OnboardModel} from './onboard' -import {isNetworkError} from '../../lib/errors' + +export const appInfo = z.object({ + build: z.string(), + name: z.string(), + namespace: z.string(), + version: z.string(), +}) +export type AppInfo = z.infer export class RootStoreModel { + agent: AtpAgent + appInfo?: AppInfo log = new LogModel() session = new SessionModel(this) - nav = new NavigationModel() - shell = new ShellUiModel() + nav = new NavigationModel(this) + shell = new ShellUiModel(this) me = new MeModel(this) onboard = new OnboardModel() profiles = new ProfilesViewModel(this) linkMetas = new LinkMetasViewModel(this) - constructor(public api: SessionServiceClient) { + constructor(agent: AtpAgent) { + this.agent = agent makeAutoObservable(this, { api: false, - resolveName: false, serialize: false, hydrate: false, }) this.initBgFetch() } - async resolveName(didOrHandle: string) { - if (!didOrHandle) { - throw new Error('Invalid handle: ""') - } - if (didOrHandle.startsWith('did:')) { - return didOrHandle - } - const res = await this.api.com.atproto.handle.resolve({handle: didOrHandle}) - return res.data.did + get api() { + return this.agent.api } - async fetchStateUpdate() { - if (!this.session.hasSession) { - return - } - try { - if (!this.session.online) { - await this.session.connect() - } - await this.me.fetchNotifications() - } catch (e: any) { - if (isNetworkError(e)) { - this.session.setOnline(false) // connection lost - } - this.log.error('Failed to fetch latest state', e) - } + setAppInfo(info: AppInfo) { + this.appInfo = info } serialize(): unknown { return { + appInfo: this.appInfo, log: this.log.serialize(), session: this.session.serialize(), me: this.me.serialize(), @@ -79,6 +71,12 @@ export class RootStoreModel { hydrate(v: unknown) { if (isObj(v)) { + if (hasProp(v, 'appInfo')) { + const appInfoParsed = appInfo.safeParse(v.appInfo) + if (appInfoParsed.success) { + this.setAppInfo(appInfoParsed.data) + } + } if (hasProp(v, 'log')) { this.log.hydrate(v.log) } @@ -100,20 +98,131 @@ export class RootStoreModel { } } - clearAll() { + /** + * Called during init to resume any stored session. + */ + async attemptSessionResumption() { + this.log.debug('RootStoreModel:attemptSessionResumption') + try { + await this.session.attemptSessionResumption() + this.log.debug('Session initialized', { + hasSession: this.session.hasSession, + }) + this.updateSessionState() + } catch (e: any) { + this.log.warn('Failed to initialize session', e) + } + } + + /** + * Called by the session model. Refreshes session-oriented state. + */ + async handleSessionChange(agent: AtpAgent) { + this.log.debug('RootStoreModel:handleSessionChange') + this.agent = agent + this.nav.clear() + this.me.clear() + await this.me.load() + } + + /** + * Called by the session model. Handles session drops by informing the user. + */ + async handleSessionDrop() { + this.log.debug('RootStoreModel:handleSessionDrop') + this.nav.clear() + this.me.clear() + this.emitSessionDropped() + } + + /** + * Clears all session-oriented state. + */ + clearAllSessionState() { + this.log.debug('RootStoreModel:clearAllSessionState') this.session.clear() this.nav.clear() this.me.clear() } + /** + * Periodic poll for new session state. + */ + async updateSessionState() { + if (!this.session.hasSession) { + return + } + try { + await this.me.follows.fetchIfNeeded() + } catch (e: any) { + this.log.error('Failed to fetch latest state', e) + } + } + + // global event bus + // = + // - some events need to be passed around between views and models + // in order to keep state in sync; these methods are for that + + // a post was deleted by the local user onPostDeleted(handler: (uri: string) => void): EmitterSubscription { return DeviceEventEmitter.addListener('post-deleted', handler) } - emitPostDeleted(uri: string) { DeviceEventEmitter.emit('post-deleted', uri) } + // the session has started and been fully hydrated + onSessionLoaded(handler: () => void): EmitterSubscription { + return DeviceEventEmitter.addListener('session-loaded', handler) + } + emitSessionLoaded() { + DeviceEventEmitter.emit('session-loaded') + } + + // the session was dropped due to bad/expired refresh tokens + onSessionDropped(handler: () => void): EmitterSubscription { + return DeviceEventEmitter.addListener('session-dropped', handler) + } + emitSessionDropped() { + DeviceEventEmitter.emit('session-dropped') + } + + // the current screen has changed + onNavigation(handler: () => void): EmitterSubscription { + return DeviceEventEmitter.addListener('navigation', handler) + } + emitNavigation() { + DeviceEventEmitter.emit('navigation') + } + + // a "soft reset" typically means scrolling to top and loading latest + // but it can depend on the screen + onScreenSoftReset(handler: () => void): EmitterSubscription { + return DeviceEventEmitter.addListener('screen-soft-reset', handler) + } + emitScreenSoftReset() { + DeviceEventEmitter.emit('screen-soft-reset') + } + + // the unread notifications count has changed + onUnreadNotifications(handler: (count: number) => void): EmitterSubscription { + return DeviceEventEmitter.addListener('unread-notifications', handler) + } + emitUnreadNotifications(count: number) { + DeviceEventEmitter.emit('unread-notifications', count) + } + + // a notification has been queued for push + onPushNotification( + handler: (notif: NotificationsViewItemModel) => void, + ): EmitterSubscription { + return DeviceEventEmitter.addListener('push-notification', handler) + } + emitPushNotification(notif: NotificationsViewItemModel) { + DeviceEventEmitter.emit('push-notification', notif) + } + // background fetch // = // - we use this to poll for unread notifications, which is not "ideal" behavior but @@ -135,7 +244,22 @@ export class RootStoreModel { async onBgFetch(taskId: string) { this.log.debug(`Background fetch fired for task ${taskId}`) if (this.session.hasSession) { - await this.me.bgFetchNotifications() + const res = await this.api.app.bsky.notification.getCount() + const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count + this.emitUnreadNotifications(res.data.count) + this.log.debug( + `Background fetch received unread count = ${res.data.count}`, + ) + if (hasNewNotifs) { + this.log.debug( + 'Background fetch detected potentially a new notification', + ) + const mostRecent = await this.me.notifications.getNewMostRecent() + if (mostRecent) { + this.log.debug('Got the notification, triggering a push') + this.emitPushNotification(mostRecent) + } + } } BgScheduler.finish(taskId) } @@ -146,7 +270,9 @@ export class RootStoreModel { } } -const throwawayInst = new RootStoreModel(AtpApi.service('http://localhost')) // this will be replaced by the loader, we just need to supply a value at init +const throwawayInst = new RootStoreModel( + new AtpAgent({service: 'http://localhost'}), +) // this will be replaced by the loader, we just need to supply a value at init const RootStoreContext = createContext(throwawayInst) export const RootStoreProvider = RootStoreContext.Provider export const useStores = () => useContext(RootStoreContext) diff --git a/src/state/models/session.ts b/src/state/models/session.ts index bc0a9123f..6e816120d 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -1,25 +1,22 @@ -import {makeAutoObservable, runInAction} from 'mobx' +import {makeAutoObservable} from 'mobx' import { - sessionClient as AtpApi, - Session, - SessionServiceClient, + AtpAgent, + AtpSessionEvent, + AtpSessionData, ComAtprotoServerGetAccountsConfig as GetAccountsConfig, } from '@atproto/api' -import {isObj, hasProp} from '../lib/type-guards' +import normalizeUrl from 'normalize-url' +import {isObj, hasProp} from 'lib/type-guards' import {z} from 'zod' import {RootStoreModel} from './root-store' -import {isNetworkError} from '../../lib/errors' export type ServiceDescription = GetAccountsConfig.OutputSchema -export const sessionData = z.object({ +export const activeSession = z.object({ service: z.string(), - refreshJwt: z.string(), - accessJwt: z.string(), - handle: z.string(), did: z.string(), }) -export type SessionData = z.infer +export type ActiveSession = z.infer export const accountData = z.object({ service: z.string(), @@ -32,18 +29,24 @@ export const accountData = z.object({ }) export type AccountData = z.infer +interface AdditionalAccountData { + displayName?: string + aviUrl?: string +} + export class SessionModel { /** - * Current session data + * Currently-active session */ - data: SessionData | null = null + data: ActiveSession | null = null /** - * A listing of the currently & previous sessions, used for account switching + * A listing of the currently & previous sessions */ accounts: AccountData[] = [] - online = false - attemptingConnect = false - private _connectPromise: Promise | undefined + /** + * Flag to indicate if we're doing our initial-load session resumption + */ + isResumingSession = false constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, { @@ -53,8 +56,22 @@ export class SessionModel { }) } + get currentSession() { + if (!this.data) { + return undefined + } + const {did, service} = this.data + return this.accounts.find( + account => + normalizeUrl(account.service) === normalizeUrl(service) && + account.did === did && + !!account.accessJwt && + !!account.refreshJwt, + ) + } + get hasSession() { - return this.data !== null + return !!this.currentSession && !!this.rootStore.agent.session } get hasAccounts() { @@ -75,8 +92,8 @@ export class SessionModel { hydrate(v: unknown) { this.accounts = [] if (isObj(v)) { - if (hasProp(v, 'data') && sessionData.safeParse(v.data)) { - this.data = v.data as SessionData + if (hasProp(v, 'data') && activeSession.safeParse(v.data)) { + this.data = v.data as ActiveSession } if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) { for (const account of v.accounts) { @@ -90,92 +107,96 @@ export class SessionModel { clear() { this.data = null - this.setOnline(false) } - setState(data: SessionData) { - this.data = data - } - - setOnline(online: boolean, attemptingConnect?: boolean) { - this.online = online - if (typeof attemptingConnect === 'boolean') { - this.attemptingConnect = attemptingConnect - } - } - - updateAuthTokens(session: Session) { - if (this.data) { - this.setState({ - ...this.data, - accessJwt: session.accessJwt, - refreshJwt: session.refreshJwt, - }) + /** + * Attempts to resume the previous session loaded from storage + */ + async attemptSessionResumption() { + const sess = this.currentSession + if (sess) { + this.rootStore.log.debug( + 'SessionModel:attemptSessionResumption found stored session', + ) + this.isResumingSession = true + try { + return await this.resumeSession(sess) + } finally { + this.isResumingSession = false + } + } else { + this.rootStore.log.debug( + 'SessionModel:attemptSessionResumption has no session to resume', + ) } } /** - * Sets up the XRPC API, must be called before connecting to a service + * Sets the active session */ - private configureApi(): boolean { - if (!this.data) { - return false + setActiveSession(agent: AtpAgent, did: string) { + this.rootStore.log.debug('SessionModel:setActiveSession') + this.data = { + service: agent.service.toString(), + did, } - - try { - const serviceUri = new URL(this.data.service) - this.rootStore.api.xrpc.uri = serviceUri - } catch (e: any) { - this.rootStore.log.error( - `Invalid service URL: ${this.data.service}. Resetting session.`, - e, - ) - this.clear() - return false - } - - this.rootStore.api.sessionManager.set({ - refreshJwt: this.data.refreshJwt, - accessJwt: this.data.accessJwt, - }) - return true + this.rootStore.handleSessionChange(agent) } /** - * Upserts the current session into the accounts + * Upserts a session into the accounts */ - private addSessionToAccounts() { - if (!this.data) { - return - } + private persistSession( + service: string, + did: string, + event: AtpSessionEvent, + session?: AtpSessionData, + addedInfo?: AdditionalAccountData, + ) { + this.rootStore.log.debug('SessionModel:persistSession', { + service, + did, + event, + hasSession: !!session, + }) + + // upsert the account in our listing const existingAccount = this.accounts.find( - acc => acc.service === this.data?.service && acc.did === this.data.did, + account => account.service === service && account.did === did, ) const newAccount = { - service: this.data.service, - refreshJwt: this.data.refreshJwt, - accessJwt: this.data.accessJwt, - handle: this.data.handle, - did: this.data.did, - displayName: this.rootStore.me.displayName, - aviUrl: this.rootStore.me.avatar, + service, + did, + refreshJwt: session?.refreshJwt, + accessJwt: session?.accessJwt, + handle: session?.handle || existingAccount?.handle || '', + displayName: addedInfo + ? addedInfo.displayName + : existingAccount?.displayName || '', + aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '', } if (!existingAccount) { this.accounts.push(newAccount) } else { - this.accounts = this.accounts - .filter( - acc => - !(acc.service === this.data?.service && acc.did === this.data.did), - ) - .concat([newAccount]) + this.accounts = [ + newAccount, + ...this.accounts.filter( + account => !(account.service === service && account.did === did), + ), + ] + } + + // if the session expired, fire an event to let the user know + if (event === 'expired') { + this.rootStore.handleSessionDrop() } } /** * Clears any session tokens from the accounts; used on logout. */ - private clearSessionTokensFromAccounts() { + private clearSessionTokens() { + this.rootStore.log.debug('SessionModel:clearSessionTokens') this.accounts = this.accounts.map(acct => ({ service: acct.service, handle: acct.handle, @@ -186,65 +207,73 @@ export class SessionModel { } /** - * Fetches the current session from the service, if possible. - * Requires an existing session (.data) to be populated with access tokens. + * Fetches additional information about an account on load. */ - async connect(): Promise { - if (this._connectPromise) { - return this._connectPromise + private async loadAccountInfo(agent: AtpAgent, did: string) { + const res = await agent.api.app.bsky.actor + .getProfile({actor: did}) + .catch(_e => undefined) + if (res) { + return { + dispayName: res.data.displayName, + aviUrl: res.data.avatar, + } } - this._connectPromise = this._connect() - const res = await this._connectPromise - this._connectPromise = undefined - return res } - private async _connect(): Promise { - this.attemptingConnect = true - if (!this.configureApi()) { + /** + * Helper to fetch the accounts config settings from an account. + */ + async describeService(service: string): Promise { + const agent = new AtpAgent({service}) + const res = await agent.api.com.atproto.server.getAccountsConfig({}) + return res.data + } + + /** + * Attempt to resume a session that we still have access tokens for. + */ + async resumeSession(account: AccountData): Promise { + this.rootStore.log.debug('SessionModel:resumeSession') + if (!(account.accessJwt && account.refreshJwt && account.service)) { + this.rootStore.log.debug( + 'SessionModel:resumeSession aborted due to lack of access tokens', + ) return false } + const agent = new AtpAgent({ + service: account.service, + persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => { + this.persistSession(account.service, account.did, evt, sess) + }, + }) + try { - const sess = await this.rootStore.api.com.atproto.session.get() - if (sess.success && this.data && this.data.did === sess.data.did) { - this.setOnline(true, false) - if (this.rootStore.me.did !== sess.data.did) { - this.rootStore.me.clear() - } - this.rootStore.me - .load() - .catch(e => { - this.rootStore.log.error( - 'Failed to fetch local user information', - e, - ) - }) - .then(() => { - this.addSessionToAccounts() - }) - return true // success - } + await agent.resumeSession({ + accessJwt: account.accessJwt, + refreshJwt: account.refreshJwt, + did: account.did, + handle: account.handle, + }) + const addedInfo = await this.loadAccountInfo(agent, account.did) + this.persistSession( + account.service, + account.did, + 'create', + agent.session, + addedInfo, + ) + this.rootStore.log.debug('SessionModel:resumeSession succeeded') } catch (e: any) { - if (isNetworkError(e)) { - this.setOnline(false, false) // connection issue - return false - } else { - this.clear() // invalid session cached - } + this.rootStore.log.debug('SessionModel:resumeSession failed', { + error: e.toString(), + }) + return false } - this.setOnline(false, false) - return false - } - - /** - * Helper to fetch the accounts config settings from an account. - */ - async describeService(service: string): Promise { - const api = AtpApi.service(service) as SessionServiceClient - const res = await api.com.atproto.server.getAccountsConfig({}) - return res.data + this.setActiveSession(agent, account.did) + return true } /** @@ -252,78 +281,32 @@ export class SessionModel { */ async login({ service, - handle, + identifier, password, }: { service: string - handle: string + identifier: string password: string }) { - const api = AtpApi.service(service) as SessionServiceClient - const res = await api.com.atproto.session.create({handle, password}) - if (res.data.accessJwt && res.data.refreshJwt) { - this.setState({ - service: service, - accessJwt: res.data.accessJwt, - refreshJwt: res.data.refreshJwt, - handle: res.data.handle, - did: res.data.did, - }) - this.configureApi() - this.setOnline(true, false) - this.rootStore.me - .load() - .catch(e => { - this.rootStore.log.error('Failed to fetch local user information', e) - }) - .then(() => { - this.addSessionToAccounts() - }) - } - } - - /** - * Attempt to resume a session that we still have access tokens for. - */ - async resumeSession(account: AccountData): Promise { - if (!(account.accessJwt && account.refreshJwt && account.service)) { - return false + this.rootStore.log.debug('SessionModel:login') + const agent = new AtpAgent({service}) + await agent.login({identifier, password}) + if (!agent.session) { + throw new Error('Failed to establish session') } - // test that the session is good - const api = AtpApi.service(account.service) - api.sessionManager.set({ - refreshJwt: account.refreshJwt, - accessJwt: account.accessJwt, - }) - try { - const sess = await api.com.atproto.session.get() - if ( - !sess.success || - sess.data.did !== account.did || - !api.sessionManager.session - ) { - return false - } + const did = agent.session.did + const addedInfo = await this.loadAccountInfo(agent, did) - // copy over the access tokens, as they may have refreshed during the .get() above - runInAction(() => { - account.refreshJwt = api.sessionManager.session?.refreshJwt - account.accessJwt = api.sessionManager.session?.accessJwt - }) - } catch (_e) { - return false - } + this.persistSession(service, did, 'create', agent.session, addedInfo) + agent.setPersistSessionHandler( + (evt: AtpSessionEvent, sess?: AtpSessionData) => { + this.persistSession(service, did, evt, sess) + }, + ) - // session is good, connect - this.setState({ - service: account.service, - accessJwt: account.accessJwt, - refreshJwt: account.refreshJwt, - handle: account.handle, - did: account.did, - }) - return this.connect() + this.setActiveSession(agent, did) + this.rootStore.log.debug('SessionModel:login succeeded') } async createAccount({ @@ -339,38 +322,41 @@ export class SessionModel { handle: string inviteCode?: string }) { - const api = AtpApi.service(service) as SessionServiceClient - const res = await api.com.atproto.account.create({ + this.rootStore.log.debug('SessionModel:createAccount') + const agent = new AtpAgent({service}) + await agent.createAccount({ handle, password, email, inviteCode, }) - if (res.data.accessJwt && res.data.refreshJwt) { - this.setState({ - service: service, - accessJwt: res.data.accessJwt, - refreshJwt: res.data.refreshJwt, - handle: res.data.handle, - did: res.data.did, - }) - this.rootStore.onboard.start() - this.configureApi() - this.rootStore.me - .load() - .catch(e => { - this.rootStore.log.error('Failed to fetch local user information', e) - }) - .then(() => { - this.addSessionToAccounts() - }) + if (!agent.session) { + throw new Error('Failed to establish session') } + + const did = agent.session.did + const addedInfo = await this.loadAccountInfo(agent, did) + + this.persistSession(service, did, 'create', agent.session, addedInfo) + agent.setPersistSessionHandler( + (evt: AtpSessionEvent, sess?: AtpSessionData) => { + this.persistSession(service, did, evt, sess) + }, + ) + + this.setActiveSession(agent, did) + this.rootStore.onboard.start() + this.rootStore.log.debug('SessionModel:createAccount succeeded') } /** * Close all sessions across all accounts. */ async logout() { + this.rootStore.log.debug('SessionModel:logout') + // TODO + // need to evaluate why deleting the session has caused errors at times + // -prf /*if (this.hasSession) { this.rootStore.api.com.atproto.session.delete().catch((e: any) => { this.rootStore.log.warn( @@ -379,7 +365,7 @@ export class SessionModel { ) }) }*/ - this.clearSessionTokensFromAccounts() - this.rootStore.clearAll() + this.clearSessionTokens() + this.rootStore.clearAllSessionState() } } diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index 09ffd265a..b9f480ecd 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -1,7 +1,8 @@ +import {RootStoreModel} from './root-store' import {makeAutoObservable} from 'mobx' import {ProfileViewModel} from './profile-view' -import {isObj, hasProp} from '../lib/type-guards' -import {PickedMedia} from '../../view/com/util/images/image-crop-picker/types' +import {isObj, hasProp} from 'lib/type-guards' +import {PickedMedia} from 'view/com/util/images/image-crop-picker/types' export class ConfirmModal { name = 'confirm' @@ -40,7 +41,7 @@ export class ServerInputModal { export class ReportPostModal { name = 'report-post' - constructor(public postUrl: string) { + constructor(public postUri: string, public postCid: string) { makeAutoObservable(this) } } @@ -59,7 +60,13 @@ export class CropImageModal { constructor( public uri: string, public onSelect: (img?: PickedMedia) => void, - ) { + ) {} +} + +export class DeleteAccountModal { + name = 'delete-account' + + constructor() { makeAutoObservable(this) } } @@ -111,14 +118,19 @@ export class ShellUiModel { | ReportPostModal | ReportAccountModal | CropImageModal + | DeleteAccountModal | undefined isLightboxActive = false activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined isComposerActive = false composerOpts: ComposerOpts | undefined - constructor() { - makeAutoObservable(this, {serialize: false, hydrate: false}) + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this, { + serialize: false, + rootStore: false, + hydrate: false, + }) } serialize(): unknown { @@ -154,8 +166,10 @@ export class ShellUiModel { | ServerInputModal | ReportPostModal | ReportAccountModal - | CropImageModal, + | CropImageModal + | DeleteAccountModal, ) { + this.rootStore.emitNavigation() this.isModalActive = true this.activeModal = modal } @@ -166,6 +180,7 @@ export class ShellUiModel { } openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) { + this.rootStore.emitNavigation() this.isLightboxActive = true this.activeLightbox = lightbox } @@ -176,6 +191,7 @@ export class ShellUiModel { } openComposer(opts: ComposerOpts) { + this.rootStore.emitNavigation() this.isComposerActive = true this.composerOpts = opts } diff --git a/src/state/models/suggested-actors-view.ts b/src/state/models/suggested-actors-view.ts index 0c9e0c3e1..4764f581e 100644 --- a/src/state/models/suggested-actors-view.ts +++ b/src/state/models/suggested-actors-view.ts @@ -1,25 +1,48 @@ -import {makeAutoObservable} from 'mobx' -import {AppBskyActorGetSuggestions as GetSuggestions} from '@atproto/api' +import {makeAutoObservable, runInAction} from 'mobx' +import {AppBskyActorProfile as Profile} from '@atproto/api' +import shuffle from 'lodash.shuffle' import {RootStoreModel} from './root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' +import { + DEV_SUGGESTED_FOLLOWS, + PROD_SUGGESTED_FOLLOWS, + STAGING_SUGGESTED_FOLLOWS, +} from 'lib/constants' const PAGE_SIZE = 30 -export type SuggestedActor = GetSuggestions.Actor +export type SuggestedActor = Profile.ViewBasic | Profile.View + +const getSuggestionList = ({serviceUrl}: {serviceUrl: string}) => { + if (serviceUrl.includes('localhost')) { + return DEV_SUGGESTED_FOLLOWS + } else if (serviceUrl.includes('staging')) { + return STAGING_SUGGESTED_FOLLOWS + } else { + return PROD_SUGGESTED_FOLLOWS + } +} export class SuggestedActorsViewModel { // state + pageSize = PAGE_SIZE isLoading = false isRefreshing = false hasLoaded = false error = '' hasMore = true loadMoreCursor?: string - private _loadMorePromise: Promise | undefined + + private hardCodedSuggestions: SuggestedActor[] | undefined // data suggestions: SuggestedActor[] = [] - constructor(public rootStore: RootStoreModel) { + constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) { + if (opts?.pageSize) { + this.pageSize = opts.pageSize + } makeAutoObservable( this, { @@ -48,13 +71,96 @@ export class SuggestedActorsViewModel { return this.loadMore(true) } - async loadMore(isRefreshing = false) { - if (this._loadMorePromise) { - return this._loadMorePromise + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + if (replace) { + this.hardCodedSuggestions = undefined + } + this._xLoading(replace) + try { + let items: SuggestedActor[] = this.suggestions + if (replace) { + items = [] + this.loadMoreCursor = undefined + } + let res + do { + await this.fetchHardcodedSuggestions() + if (this.hardCodedSuggestions && this.hardCodedSuggestions.length > 0) { + // pull from the hard-coded suggestions + const newItems = this.hardCodedSuggestions.splice(0, this.pageSize) + items = items.concat(newItems) + this.hasMore = true + this.loadMoreCursor = undefined + } else { + // pull from the PDS' algo + res = await this.rootStore.api.app.bsky.actor.getSuggestions({ + limit: this.pageSize, + cursor: this.loadMoreCursor, + }) + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + items = items.concat( + res.data.actors.filter( + actor => !items.find(i => i.did === actor.did), + ), + ) + } + } while (items.length < this.pageSize && this.hasMore) + runInAction(() => { + this.suggestions = items + }) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + private async fetchHardcodedSuggestions() { + if (this.hardCodedSuggestions) { + return + } + await this.rootStore.me.follows.fetchIfNeeded() + try { + // clone the array so we can mutate it + const actors = [ + ...getSuggestionList({ + serviceUrl: this.rootStore.session.currentSession?.service || '', + }), + ] + + // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`) + let profiles: Profile.View[] = [] + do { + const res = await this.rootStore.api.app.bsky.actor.getProfiles({ + actors: actors.splice(0, 25), + }) + profiles = profiles.concat(res.data.profiles) + } while (actors.length) + + runInAction(() => { + profiles = profiles.filter(profile => { + if (this.rootStore.me.follows.isFollowing(profile.did)) { + return false + } + if (profile.did === this.rootStore.me.did) { + return false + } + return true + }) + this.hardCodedSuggestions = shuffle(profiles) + }) + } catch (e) { + this.rootStore.log.error( + 'Failed to getProfiles() for suggested follows', + {e}, + ) + runInAction(() => { + this.hardCodedSuggestions = [] + }) } - this._loadMorePromise = this._loadMore(isRefreshing) - await this._loadMorePromise - this._loadMorePromise = undefined } // state transitions @@ -70,52 +176,9 @@ export class SuggestedActorsViewModel { this.isLoading = false this.isRefreshing = false this.hasLoaded = true - this.error = err ? err.toString() : '' + this.error = cleanError(err) if (err) { this.rootStore.log.error('Failed to fetch suggested actors', err) } } - - // loader functions - // = - - private async _loadMore(isRefreshing = false) { - if (!this.hasMore) { - return - } - this._xLoading(isRefreshing) - try { - if (this.isRefreshing) { - this.suggestions = [] - } - let res - let totalAdded = 0 - do { - res = await this.rootStore.api.app.bsky.actor.getSuggestions({ - limit: PAGE_SIZE, - cursor: this.loadMoreCursor, - }) - totalAdded += await this._appendAll(res) - } while (totalAdded < PAGE_SIZE && this.hasMore) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - private async _appendAll(res: GetSuggestions.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - const newSuggestions = res.data.actors.filter(actor => { - if (actor.did === this.rootStore.me.did) { - return false // skip self - } - if (actor.myState?.follow) { - return false // skip already-followed users - } - return true - }) - this.suggestions = this.suggestions.concat(newSuggestions) - return newSuggestions.length - } } diff --git a/src/state/models/suggested-posts-view.ts b/src/state/models/suggested-posts-view.ts new file mode 100644 index 000000000..7b44370de --- /dev/null +++ b/src/state/models/suggested-posts-view.ts @@ -0,0 +1,148 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import { + AppBskyFeedFeedViewPost, + AppBskyFeedGetAuthorFeed as GetAuthorFeed, +} from '@atproto/api' +type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost +import {RootStoreModel} from './root-store' +import {FeedItemModel} from './feed-view' +import {cleanError} from 'lib/strings/errors' + +const TEAM_HANDLES = [ + 'jay.bsky.social', + 'paul.bsky.social', + 'dan.bsky.social', + 'divy.bsky.social', + 'why.bsky.social', + 'iamrosewang.bsky.social', +] + +export class SuggestedPostsView { + // state + isLoading = false + hasLoaded = false + error = '' + + // data + posts: FeedItemModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.posts.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async setup() { + this._xLoading() + try { + const responses = await Promise.all( + TEAM_HANDLES.map(handle => + this.rootStore.api.app.bsky.feed + .getAuthorFeed({author: handle, limit: 10}) + .catch(_err => ({success: false, headers: {}, data: {feed: []}})), + ), + ) + runInAction(() => { + this.posts = mergeAndFilterResponses(this.rootStore, responses) + }) + this._xIdle() + } catch (e: any) { + this.rootStore.log.error('SuggestedPostsView: Failed to load posts', { + e, + }) + this._xIdle() // dont bubble to the user + } + } + + // state transitions + // = + + private _xLoading() { + this.isLoading = true + this.error = '' + } + + private _xIdle(err?: any) { + this.isLoading = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch suggested posts', err) + } + } +} + +function mergeAndFilterResponses( + store: RootStoreModel, + responses: GetAuthorFeed.Response[], +): FeedItemModel[] { + let posts: AppBskyFeedFeedViewPost.Main[] = [] + + // merge into one array + for (const res of responses) { + if (res.success) { + posts = posts.concat(res.data.feed) + } + } + + // filter down to reposts of other users + const now = Date.now() + const uris = new Set() + posts = posts.filter(p => { + if (isARepostOfSomeoneElse(p) && isRecentEnough(now, p)) { + if (uris.has(p.post.uri)) { + return false + } + uris.add(p.post.uri) + return true + } + return false + }) + + // sort by index time + posts.sort((a, b) => { + return ( + Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt)) + ) + }) + + // hydrate into models and strip the reasons to hide that these are reposts + return posts.map((post, i) => { + delete post.reason + return new FeedItemModel(store, `post-${i}`, post) + }) +} + +function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean { + return ( + post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' && + post.post.author.did !== (post.reason as ReasonRepost).by.did + ) +} + +const THREE_DAYS = 3 * 24 * 60 * 60 * 1000 +function isRecentEnough( + now: number, + post: AppBskyFeedFeedViewPost.Main, +): boolean { + return now - Number(new Date(post.post.indexedAt)) < THREE_DAYS +} diff --git a/src/state/models/user-autocomplete-view.ts b/src/state/models/user-autocomplete-view.ts index 8f467da69..8e4211c27 100644 --- a/src/state/models/user-autocomplete-view.ts +++ b/src/state/models/user-autocomplete-view.ts @@ -1,8 +1,6 @@ import {makeAutoObservable, runInAction} from 'mobx' -import { - AppBskyGraphGetFollows as GetFollows, - AppBskyActorSearchTypeahead as SearchTypeahead, -} from '@atproto/api' +import {AppBskyActorRef} from '@atproto/api' +import AwaitLock from 'await-lock' import {RootStoreModel} from './root-store' export class UserAutocompleteViewModel { @@ -10,11 +8,11 @@ export class UserAutocompleteViewModel { isLoading = false isActive = false prefix = '' - _searchPromise: Promise | undefined + lock = new AwaitLock() // data - follows: GetFollows.Follow[] = [] - searchRes: SearchTypeahead.User[] = [] + follows: AppBskyActorRef.WithInfo[] = [] + searchRes: AppBskyActorRef.WithInfo[] = [] knownHandles: Set = new Set() constructor(public rootStore: RootStoreModel) { @@ -58,16 +56,20 @@ export class UserAutocompleteViewModel { } async setPrefix(prefix: string) { - const origPrefix = prefix - this.prefix = prefix.trim() - if (this.prefix) { - await this._searchPromise - if (this.prefix !== origPrefix) { - return // another prefix was set before we got our chance + const origPrefix = prefix.trim() + this.prefix = origPrefix + await this.lock.acquireAsync() + try { + if (this.prefix) { + if (this.prefix !== origPrefix) { + return // another prefix was set before we got our chance + } + await this._search() + } else { + this.searchRes = [] } - this._searchPromise = this._search() - } else { - this.searchRes = [] + } finally { + this.lock.release() } } diff --git a/src/state/models/user-followers-view.ts b/src/state/models/user-followers-view.ts index 9daaf35a4..7400262a4 100644 --- a/src/state/models/user-followers-view.ts +++ b/src/state/models/user-followers-view.ts @@ -4,10 +4,12 @@ import { AppBskyActorRef as ActorRef, } from '@atproto/api' import {RootStoreModel} from './root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' const PAGE_SIZE = 30 -export type FollowerItem = GetFollowers.Follower +export type FollowerItem = ActorRef.WithInfo export class UserFollowersViewModel { // state @@ -18,7 +20,6 @@ export class UserFollowersViewModel { params: GetFollowers.QueryParams hasMore = true loadMoreCursor?: string - private _loadMorePromise: Promise | undefined // data subject: ActorRef.WithInfo = { @@ -62,14 +63,27 @@ export class UserFollowersViewModel { return this.loadMore(true) } - async loadMore(isRefreshing = false) { - if (this._loadMorePromise) { - return this._loadMorePromise + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return } - this._loadMorePromise = this._loadMore(isRefreshing) - await this._loadMorePromise - this._loadMorePromise = undefined - } + this._xLoading(replace) + try { + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + before: replace ? undefined : this.loadMoreCursor, + }) + const res = await this.rootStore.api.app.bsky.graph.getFollowers(params) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) // state transitions // = @@ -84,39 +98,24 @@ export class UserFollowersViewModel { this.isLoading = false this.isRefreshing = false this.hasLoaded = true - this.error = err ? err.toString() : '' + this.error = cleanError(err) if (err) { this.rootStore.log.error('Failed to fetch user followers', err) } } - // loader functions + // helper functions // = - private async _loadMore(isRefreshing = false) { - if (!this.hasMore) { - return - } - this._xLoading(isRefreshing) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - before: this.loadMoreCursor, - }) - if (this.isRefreshing) { - this.followers = [] - } - const res = await this.rootStore.api.app.bsky.graph.getFollowers(params) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } + private _replaceAll(res: GetFollowers.Response) { + this.followers = [] + this._appendAll(res) } - private async _appendAll(res: GetFollowers.Response) { + private _appendAll(res: GetFollowers.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.followers = this.followers.concat(res.data.followers) + this.rootStore.me.follows.hydrateProfiles(res.data.followers) } } diff --git a/src/state/models/user-follows-view.ts b/src/state/models/user-follows-view.ts index d43a10c75..7d28d7ebd 100644 --- a/src/state/models/user-follows-view.ts +++ b/src/state/models/user-follows-view.ts @@ -4,10 +4,12 @@ import { AppBskyActorRef as ActorRef, } from '@atproto/api' import {RootStoreModel} from './root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' const PAGE_SIZE = 30 -export type FollowItem = GetFollows.Follow +export type FollowItem = ActorRef.WithInfo export class UserFollowsViewModel { // state @@ -18,7 +20,6 @@ export class UserFollowsViewModel { params: GetFollows.QueryParams hasMore = true loadMoreCursor?: string - private _loadMorePromise: Promise | undefined // data subject: ActorRef.WithInfo = { @@ -62,14 +63,27 @@ export class UserFollowsViewModel { return this.loadMore(true) } - async loadMore(isRefreshing = false) { - if (this._loadMorePromise) { - return this._loadMorePromise + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return } - this._loadMorePromise = this._loadMore(isRefreshing) - await this._loadMorePromise - this._loadMorePromise = undefined - } + this._xLoading(replace) + try { + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + before: replace ? undefined : this.loadMoreCursor, + }) + const res = await this.rootStore.api.app.bsky.graph.getFollows(params) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) // state transitions // = @@ -84,39 +98,24 @@ export class UserFollowsViewModel { this.isLoading = false this.isRefreshing = false this.hasLoaded = true - this.error = err ? err.toString() : '' + this.error = cleanError(err) if (err) { this.rootStore.log.error('Failed to fetch user follows', err) } } - // loader functions + // helper functions // = - private async _loadMore(isRefreshing = false) { - if (!this.hasMore) { - return - } - this._xLoading(isRefreshing) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - before: this.loadMoreCursor, - }) - if (this.isRefreshing) { - this.follows = [] - } - const res = await this.rootStore.api.app.bsky.graph.getFollows(params) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } + private _replaceAll(res: GetFollows.Response) { + this.follows = [] + this._appendAll(res) } - private async _appendAll(res: GetFollows.Response) { + private _appendAll(res: GetFollows.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.follows = this.follows.concat(res.data.follows) + this.rootStore.me.follows.hydrateProfiles(res.data.follows) } } diff --git a/src/state/models/votes-view.ts b/src/state/models/votes-view.ts index df939226f..ad8698d21 100644 --- a/src/state/models/votes-view.ts +++ b/src/state/models/votes-view.ts @@ -2,6 +2,9 @@ import {makeAutoObservable, runInAction} from 'mobx' import {AtUri} from '../../third-party/uri' import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api' import {RootStoreModel} from './root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' +import * as apilib from 'lib/api/index' const PAGE_SIZE = 30 @@ -17,7 +20,6 @@ export class VotesViewModel { params: GetVotes.QueryParams hasMore = true loadMoreCursor?: string - private _loadMorePromise: Promise | undefined // data uri: string = '' @@ -54,17 +56,31 @@ export class VotesViewModel { return this.loadMore(true) } - async loadMore(isRefreshing = false) { - if (this._loadMorePromise) { - return this._loadMorePromise + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return } - if (!this.resolvedUri) { - await this._resolveUri() + this._xLoading(replace) + try { + if (!this.resolvedUri) { + await this._resolveUri() + } + const params = Object.assign({}, this.params, { + uri: this.resolvedUri, + limit: PAGE_SIZE, + before: replace ? undefined : this.loadMoreCursor, + }) + const res = await this.rootStore.api.app.bsky.feed.getVotes(params) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) } - this._loadMorePromise = this._loadMore(isRefreshing) - await this._loadMorePromise - this._loadMorePromise = undefined - } + }) // state transitions // = @@ -79,20 +95,20 @@ export class VotesViewModel { this.isLoading = false this.isRefreshing = false this.hasLoaded = true - this.error = err ? err.toString() : '' + this.error = cleanError(err) if (err) { this.rootStore.log.error('Failed to fetch votes', err) } } - // loader functions + // helper functions // = private async _resolveUri() { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { - urip.host = await this.rootStore.resolveName(urip.host) + urip.host = await apilib.resolveName(this.rootStore, urip.host) } catch (e: any) { this.error = e.toString() } @@ -102,23 +118,9 @@ export class VotesViewModel { }) } - private async _loadMore(isRefreshing = false) { - this._xLoading(isRefreshing) - try { - const params = Object.assign({}, this.params, { - uri: this.resolvedUri, - limit: PAGE_SIZE, - before: this.loadMoreCursor, - }) - if (this.isRefreshing) { - this.votes = [] - } - const res = await this.rootStore.api.app.bsky.feed.getVotes(params) - this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } + private _replaceAll(res: GetVotes.Response) { + this.votes = [] + this._appendAll(res) } private _appendAll(res: GetVotes.Response) { diff --git a/src/view/com/composer/Autocomplete.tsx b/src/view/com/composer/Autocomplete.tsx index 94381e644..c17e41b37 100644 --- a/src/view/com/composer/Autocomplete.tsx +++ b/src/view/com/composer/Autocomplete.tsx @@ -5,9 +5,9 @@ import { StyleSheet, useWindowDimensions, } from 'react-native' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' interface AutocompleteItem { handle: string diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index 1144b5e48..6431a11aa 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -3,10 +3,12 @@ import {observer} from 'mobx-react-lite' import { ActivityIndicator, KeyboardAvoidingView, + NativeSyntheticEvent, Platform, SafeAreaView, ScrollView, StyleSheet, + TextInputSelectionChangeEventData, TouchableOpacity, TouchableWithoutFeedback, View, @@ -16,8 +18,9 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -// import {useAnalytics} from '@segment/analytics-react-native' TODO -import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view' +import {useAnalytics} from 'lib/analytics' +import _isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {Autocomplete} from './Autocomplete' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' @@ -26,24 +29,27 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {CharProgress} from './char-progress/CharProgress' import {TextLink} from '../util/Link' import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import * as apilib from '../../../state/lib/api' -import {ComposerOpts} from '../../../state/models/shell-ui' -import {s, colors, gradients} from '../../lib/styles' -import { - detectLinkables, - extractEntities, - cleanError, -} from '../../../lib/strings' -import {getLinkMeta} from '../../../lib/link-meta' -import {downloadAndResize} from '../../../lib/images' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' +import {ComposerOpts} from 'state/models/shell-ui' +import {s, colors, gradients} from 'lib/styles' +import {cleanError} from 'lib/strings/errors' +import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' +import {getLinkMeta} from 'lib/link-meta/link-meta' +import {downloadAndResize} from 'lib/images' import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker' +import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' import {SelectedPhoto} from './SelectedPhoto' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' const MAX_TEXT_LENGTH = 256 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} +interface Selection { + start: number + end: number +} + export const ComposePost = observer(function ComposePost({ replyTo, imagesOpen, @@ -55,10 +61,11 @@ export const ComposePost = observer(function ComposePost({ onPost?: ComposerOpts['onPost'] onClose: () => void }) { - // const {track} = useAnalytics() TODO + const {track, screen} = useAnalytics() const pal = usePalette('default') const store = useStores() const textInput = useRef(null) + const textInputSelection = useRef({start: 0, end: 0}) const [isProcessing, setIsProcessing] = useState(false) const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') @@ -66,7 +73,9 @@ export const ComposePost = observer(function ComposePost({ const [extLink, setExtLink] = useState( undefined, ) - const [attemptedExtLinks, setAttemptedExtLinks] = useState([]) + const [suggestedExtLinks, setSuggestedExtLinks] = useState>( + new Set(), + ) const [isSelectingPhotos, setIsSelectingPhotos] = useState( imagesOpen || false, ) @@ -117,10 +126,10 @@ export const ComposePost = observer(function ComposePost({ if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { downloadAndResize({ uri: extLink.meta.image, - width: 250, - height: 250, + width: 2000, + height: 2000, mode: 'contain', - maxSize: 100000, + maxSize: 1000000, timeout: 15e3, }) .catch(() => undefined) @@ -166,6 +175,7 @@ export const ComposePost = observer(function ComposePost({ textInput.current?.focus() } const onPressSelectPhotos = () => { + track('ComposePost:SelectPhotos') if (isSelectingPhotos) { setIsSelectingPhotos(false) } else if (selectedPhotos.length < 4) { @@ -173,35 +183,31 @@ export const ComposePost = observer(function ComposePost({ } } const onSelectPhotos = (photos: string[]) => { + track('ComposePost:SelectPhotos:Done') setSelectedPhotos(photos) if (photos.length >= 4) { setIsSelectingPhotos(false) } } + const onPressAddLinkCard = (uri: string) => { + setExtLink({uri, isLoading: true}) + } const onChangeText = (newText: string) => { setText(newText) - const prefix = extractTextAutocompletePrefix(newText) - if (typeof prefix === 'string') { + const prefix = getMentionAt(newText, textInputSelection.current?.start || 0) + if (prefix) { autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix) + autocompleteView.setPrefix(prefix.value) } else { autocompleteView.setActive(false) } - if (!extLink && /\s$/.test(newText)) { - const ents = extractEntities(newText) - const entLink = ents - ?.filter( - ent => ent.type === 'link' && !attemptedExtLinks.includes(ent.value), - ) - .pop() // use last - if (entLink) { - setExtLink({ - uri: entLink.value, - isLoading: true, - }) - setAttemptedExtLinks([...attemptedExtLinks, entLink.value]) + if (!extLink) { + const ents = extractEntities(newText)?.filter(ent => ent.type === 'link') + const set = new Set(ents ? ents.map(e => e.value) : []) + if (!_isEqual(set, suggestedExtLinks)) { + setSuggestedExtLinks(set) } } } @@ -218,6 +224,16 @@ export const ComposePost = observer(function ComposePost({ onSelectPhotos([...selectedPhotos, finalImgPath]) } } + const onSelectionChange = ( + evt: NativeSyntheticEvent, + ) => { + // NOTE we track the input selection using a ref to avoid excessive renders -prf + textInputSelection.current = evt.nativeEvent.selection + } + const onSelectAutocompleteItem = (item: string) => { + setText(insertMentionAt(text, textInputSelection.current?.start || 0, item)) + autocompleteView.setActive(false) + } const onPressCancel = () => hackfixOnClose() const onPressPublish = async () => { if (isProcessing) { @@ -242,11 +258,15 @@ export const ComposePost = observer(function ComposePost({ autocompleteView.knownHandles, setProcessingState, ) - // TODO - // track('Create Post', { - // imageCount: selectedPhotos.length, - // }) + track('Create Post', { + imageCount: selectedPhotos.length, + }) } catch (e: any) { + setExtLink({ + ...extLink, + isLoading: true, + localThumb: undefined, + } as apilib.ExternalEmbedDraft) setError(cleanError(e.message)) setIsProcessing(false) return @@ -256,10 +276,6 @@ export const ComposePost = observer(function ComposePost({ hackfixOnClose() Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) } - const onSelectAutocompleteItem = (item: string) => { - setText(replaceTextAutocompletePrefix(text, item)) - autocompleteView.setActive(false) - } const canPost = text.length <= MAX_TEXT_LENGTH @@ -386,6 +402,7 @@ export const ComposePost = observer(function ComposePost({ innerRef={textInput} onChangeText={(str: string) => onChangeText(str)} onPaste={onPaste} + onSelectionChange={onSelectionChange} placeholder={selectTextInputPlaceholder} style={[ pal.text, @@ -406,12 +423,27 @@ export const ComposePost = observer(function ComposePost({ /> )} - {isSelectingPhotos && selectedPhotos.length < 4 && ( + {isSelectingPhotos && selectedPhotos.length < 4 ? ( - )} + ) : !extLink && + selectedPhotos.length === 0 && + suggestedExtLinks.size > 0 ? ( + + {Array.from(suggestedExtLinks).map(url => ( + onPressAddLinkCard(url)}> + + Add link card: {url} + + + ))} + + ) : null} {link.isLoading ? ( + style={[styles.image, {backgroundColor: pal.colors.backgroundLight}]}> ) : link.localThumb ? ( - - ) : ( - - )} - - - - - + + ) : undefined} {!!link.meta?.title && ( @@ -81,6 +61,11 @@ export const ExternalEmbed = ({ )} + + + + + ) } @@ -98,10 +83,7 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 6, borderTopRightRadius: 6, width: '100%', - height: 200, - }, - imageFallback: { - height: 160, + maxHeight: 200, }, removeBtn: { position: 'absolute', diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index d0c023e25..46a0cec62 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -2,7 +2,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' export function ComposePrompt({ text = "What's up?", diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/SelectedPhoto.tsx index dd508fe1f..6aeda33cd 100644 --- a/src/view/com/composer/SelectedPhoto.tsx +++ b/src/view/com/composer/SelectedPhoto.tsx @@ -1,7 +1,8 @@ import React, {useCallback} from 'react' -import {Image, StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {colors} from '../../lib/styles' +import Image from 'view/com/util/images/Image' +import {colors} from 'lib/styles' export const SelectedPhoto = ({ selectedPhotos, diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index d4093064b..cd7cb2c4e 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -5,7 +5,7 @@ import {Text} from '../../util/text/Text' import ProgressCircle from 'react-native-progress/Circle' // @ts-ignore no type definition -prf import ProgressPie from 'react-native-progress/Pie' -import {s, colors} from '../../../lib/styles' +import {s, colors} from 'lib/styles' const MAX_TEXT_LENGTH = 256 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH diff --git a/src/view/com/composer/char-progress/CharProgress.web.tsx b/src/view/com/composer/char-progress/CharProgress.web.tsx index dfb2fad58..d32d7a72c 100644 --- a/src/view/com/composer/char-progress/CharProgress.web.tsx +++ b/src/view/com/composer/char-progress/CharProgress.web.tsx @@ -1,7 +1,7 @@ import React from 'react' import {View} from 'react-native' import {Text} from '../../util/text/Text' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' const MAX_TEXT_LENGTH = 256 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx index 7a5c9f65d..406f8b04c 100644 --- a/src/view/com/composer/photos/PhotoCarouselPicker.tsx +++ b/src/view/com/composer/photos/PhotoCarouselPicker.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {useAnalytics} from 'lib/analytics' import { openPicker, openCamera, @@ -12,18 +13,26 @@ import { import { UserLocalPhotosModel, PhotoIdentifier, -} from '../../../../state/models/user-local-photos' -import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images' -import {usePalette} from '../../../lib/hooks/usePalette' -import {useStores, RootStoreModel} from '../../../../state' +} from 'state/models/user-local-photos' +import { + compressIfNeeded, + moveToPremanantPath, + scaleDownDimensions, +} from 'lib/images' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores, RootStoreModel} from 'state/index' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from 'lib/permissions' -const MAX_WIDTH = 1000 -const MAX_HEIGHT = 1000 -const MAX_SIZE = 300000 +const MAX_WIDTH = 2000 +const MAX_HEIGHT = 2000 +const MAX_SIZE = 1000000 const IMAGE_PARAMS = { - width: 1000, - height: 1000, + width: 2000, + height: 2000, freeStyleCropEnabled: true, } @@ -46,8 +55,10 @@ export async function cropPhoto( width, height, }) + const img = await compressIfNeeded(cropperRes, MAX_SIZE) - return img.path + const permanentPath = await moveToPremanantPath(img.path) + return permanentPath } export const PhotoCarouselPicker = ({ @@ -57,24 +68,28 @@ export const PhotoCarouselPicker = ({ selectedPhotos: string[] onSelectPhotos: (v: string[]) => void }) => { + const {track} = useAnalytics() const pal = usePalette('default') const store = useStores() - const [localPhotos, setLocalPhotos] = React.useState< - UserLocalPhotosModel | undefined - >(undefined) + const [isSetup, setIsSetup] = React.useState(false) + + const localPhotos = React.useMemo( + () => new UserLocalPhotosModel(store), + [store], + ) - // initial setup React.useEffect(() => { - const photos = new UserLocalPhotosModel(store) - photos.setup().then(() => { - if (photos.photos) { - setLocalPhotos(photos) - } + // initial setup + localPhotos.setup().then(() => { + setIsSetup(true) }) - }, [store]) + }, [localPhotos]) const handleOpenCamera = useCallback(async () => { try { + if (!(await requestCameraAccessIfNeeded())) { + return + } const cameraRes = await openCamera(store, { mediaType: 'photo', ...IMAGE_PARAMS, @@ -89,6 +104,7 @@ export const PhotoCarouselPicker = ({ const handleSelectPhoto = useCallback( async (item: PhotoIdentifier) => { + track('PhotoCarouselPicker:PhotoSelected') try { const imgPath = await cropPhoto( store, @@ -102,37 +118,41 @@ export const PhotoCarouselPicker = ({ store.log.warn('Error selecting photo', err) } }, - [store, selectedPhotos, onSelectPhotos], + [track, store, onSelectPhotos, selectedPhotos], ) - const handleOpenGallery = useCallback(() => { - openPicker(store, { + const handleOpenGallery = useCallback(async () => { + track('PhotoCarouselPicker:GalleryOpened') + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { multiple: true, maxFiles: 4 - selectedPhotos.length, mediaType: 'photo', - }).then(async items => { - const result = [] - - for (const image of items) { - // choose target dimensions based on the original - // this causes the photo cropper to start with the full image "selected" - const {width, height} = scaleDownDimensions( - {width: image.width, height: image.height}, - {width: MAX_WIDTH, height: MAX_HEIGHT}, - ) - const cropperRes = await openCropper(store, { - mediaType: 'photo', - path: image.path, - freeStyleCropEnabled: true, - width, - height, - }) - const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE) - result.push(finalImg.path) - } - onSelectPhotos([...selectedPhotos, ...result]) }) - }, [store, selectedPhotos, onSelectPhotos]) + const result = [] + + for (const image of items) { + // choose target dimensions based on the original + // this causes the photo cropper to start with the full image "selected" + const {width, height} = scaleDownDimensions( + {width: image.width, height: image.height}, + {width: MAX_WIDTH, height: MAX_HEIGHT}, + ) + const cropperRes = await openCropper(store, { + mediaType: 'photo', + path: image.path, + ...IMAGE_PARAMS, + width, + height, + }) + const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE) + const permanentPath = await moveToPremanantPath(finalImg.path) + result.push(permanentPath) + } + onSelectPhotos([...selectedPhotos, ...result]) + }, [track, store, selectedPhotos, onSelectPhotos]) return ( - {localPhotos != null && + {isSetup && localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( onChangeText: (str: string) => void + onSelectionChange?: + | ((e: NativeSyntheticEvent) => void) + | undefined onPaste: (err: string | undefined, uris: string[]) => void } @@ -23,6 +31,7 @@ export function TextInput({ placeholder, style, onChangeText, + onSelectionChange, onPaste, children, }: React.PropsWithChildren) { @@ -44,6 +53,7 @@ export function TextInput({ multiline scrollEnabled onChangeText={(str: string) => onChangeText(str)} + onSelectionChange={onSelectionChange} onPaste={onPasteInner} placeholder={placeholder} placeholderTextColor={pal.colors.textLight} diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 395f8e5a2..2b610850c 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,12 +1,14 @@ import React from 'react' import { + NativeSyntheticEvent, StyleProp, StyleSheet, TextInput as RNTextInput, + TextInputSelectionChangeEventData, TextStyle, } from 'react-native' -import {usePalette} from '../../../lib/hooks/usePalette' -import {addStyle} from '../../../lib/addStyle' +import {usePalette} from 'lib/hooks/usePalette' +import {addStyle} from 'lib/styles' export type TextInputRef = RNTextInput @@ -16,6 +18,9 @@ interface TextInputProps { placeholder: string style: StyleProp onChangeText: (str: string) => void + onSelectionChange?: + | ((e: NativeSyntheticEvent) => void) + | undefined onPaste: (err: string | undefined, uris: string[]) => void } @@ -25,6 +30,7 @@ export function TextInput({ placeholder, style, onChangeText, + onSelectionChange, children, }: React.PropsWithChildren) { const pal = usePalette('default') @@ -36,6 +42,7 @@ export function TextInput({ multiline scrollEnabled onChangeText={(str: string) => onChangeText(str)} + onSelectionChange={onSelectionChange} placeholder={placeholder} placeholderTextColor={pal.colors.textLight} style={style}> diff --git a/src/view/com/discover/LiteSuggestedFollows.tsx b/src/view/com/discover/LiteSuggestedFollows.tsx index ce01af7c7..5314e691c 100644 --- a/src/view/com/discover/LiteSuggestedFollows.tsx +++ b/src/view/com/discover/LiteSuggestedFollows.tsx @@ -8,19 +8,18 @@ import { import LinearGradient from 'react-native-linear-gradient' import {observer} from 'mobx-react-lite' import _omit from 'lodash.omit' -import {ErrorMessage} from '../util/error/ErrorMessage' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import * as Toast from '../util/Toast' -import {useStores} from '../../../state' -import * as apilib from '../../../state/lib/api' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' import { SuggestedActorsViewModel, SuggestedActor, -} from '../../../state/models/suggested-actors-view' -import {s, gradients} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +} from 'state/models/suggested-actors-view' +import {s, gradients} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' export const LiteSuggestedFollows = observer(() => { const store = useStores() diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index 9ed893148..1e40956ce 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -1,51 +1,28 @@ -import React, {useEffect, useState} from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import LinearGradient from 'react-native-linear-gradient' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {observer} from 'mobx-react-lite' -import _omit from 'lodash.omit' +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' +import {observer} from 'mobx-react-lite' import {ErrorScreen} from '../util/error/ErrorScreen' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' -import * as Toast from '../util/Toast' -import {useStores} from '../../../state' -import * as apilib from '../../../state/lib/api' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {useStores} from 'state/index' import { SuggestedActorsViewModel, SuggestedActor, -} from '../../../state/models/suggested-actors-view' -import {s, gradients} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +} from 'state/models/suggested-actors-view' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' export const SuggestedFollows = observer( - ({ - onNoSuggestions, - asLinks, - }: { - onNoSuggestions?: () => void - asLinks?: boolean - }) => { + ({onNoSuggestions}: {onNoSuggestions?: () => void}) => { const pal = usePalette('default') const store = useStores() - const [follows, setFollows] = useState>({}) - // Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment const view = React.useMemo( () => new SuggestedActorsViewModel(store), [store], ) - useEffect(() => { + React.useEffect(() => { view .loadMore() .catch((err: any) => @@ -53,7 +30,7 @@ export const SuggestedFollows = observer( ) }, [view, store.log]) - useEffect(() => { + React.useEffect(() => { if (!view.isLoading && !view.hasError && !view.hasContent) { onNoSuggestions?.() } @@ -74,46 +51,16 @@ export const SuggestedFollows = observer( ) } - const onPressFollow = async (item: SuggestedActor) => { - try { - const res = await apilib.follow(store, item.did, item.declaration.cid) - setFollows({[item.did]: res.uri, ...follows}) - } catch (e: any) { - store.log.error('Failed fo create follow', e) - Toast.show('An issue occurred, please try again.') - } - } - const onPressUnfollow = async (item: SuggestedActor) => { - try { - await apilib.unfollow(store, follows[item.did]) - setFollows(_omit(follows, [item.did])) - } catch (e: any) { - store.log.error('Failed fo delete follow', e) - Toast.show('An issue occurred, please try again.') - } - } - const renderItem = ({item}: {item: SuggestedActor}) => { - if (asLinks) { - return ( - - - - ) - } return ( - ) } @@ -146,7 +93,6 @@ export const SuggestedFollows = observer( )} contentContainerStyle={s.contentContainer} - style={s.flex1} /> )} @@ -155,128 +101,16 @@ export const SuggestedFollows = observer( }, ) -const User = ({ - item, - follow, - onPressFollow, - onPressUnfollow, -}: { - item: SuggestedActor - follow: string | undefined - onPressFollow: (item: SuggestedActor) => void - onPressUnfollow: (item: SuggestedActor) => void -}) => { - const pal = usePalette('default') - return ( - - - - - - - - {item.displayName || item.handle} - - - @{item.handle} - - - - {follow ? ( - onPressUnfollow(item)}> - - - Unfollow - - - - ) : ( - onPressFollow(item)}> - - - Follow - - - )} - - - {item.description ? ( - - - {item.description} - - - ) : undefined} - - ) -} - const styles = StyleSheet.create({ container: { - flex: 1, + height: '100%', }, suggestionsContainer: { - flex: 1, + height: '100%', }, footer: { height: 200, paddingTop: 20, }, - - actor: { - borderTopWidth: 1, - }, - actorMeta: { - flexDirection: 'row', - }, - actorAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - actorContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - }, - actorBtn: { - paddingRight: 10, - paddingTop: 10, - }, - actorDetails: { - paddingLeft: 60, - paddingRight: 10, - paddingBottom: 10, - }, - - gradientBtn: { - paddingHorizontal: 24, - paddingVertical: 6, - }, - secondaryBtn: { - paddingHorizontal: 14, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 7, - borderRadius: 50, - marginLeft: 6, - }, }) diff --git a/src/view/com/discover/SuggestedPosts.tsx b/src/view/com/discover/SuggestedPosts.tsx new file mode 100644 index 000000000..86a6bd394 --- /dev/null +++ b/src/view/com/discover/SuggestedPosts.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {SuggestedPostsView} from 'state/models/suggested-posts-view' +import {s} from 'lib/styles' +import {FeedItem as Post} from '../posts/FeedItem' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' + +export const SuggestedPosts = observer(() => { + const pal = usePalette('default') + const store = useStores() + const suggestedPostsView = React.useMemo( + () => new SuggestedPostsView(store), + [store], + ) + + React.useEffect(() => { + if (!suggestedPostsView.hasLoaded) { + suggestedPostsView.setup() + } + }, [store, suggestedPostsView]) + + return ( + <> + {(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && ( + + Recently, on Bluesky... + + )} + {suggestedPostsView.hasContent && ( + <> + + {suggestedPostsView.posts.map(item => ( + + ))} + + + )} + {suggestedPostsView.isLoading && ( + + + + )} + + ) +}) + +const styles = StyleSheet.create({ + heading: { + fontWeight: 'bold', + paddingHorizontal: 12, + paddingTop: 16, + paddingBottom: 8, + }, + + bottomBorder: { + borderBottomWidth: 1, + }, + + loadMore: { + paddingLeft: 12, + paddingVertical: 10, + }, +}) diff --git a/src/view/com/discover/WhoToFollow.tsx b/src/view/com/discover/WhoToFollow.tsx new file mode 100644 index 000000000..17c10ca7e --- /dev/null +++ b/src/view/com/discover/WhoToFollow.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {SuggestedActorsViewModel} from 'state/models/suggested-actors-view' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {Text} from '../util/text/Text' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' + +export const WhoToFollow = observer(() => { + const pal = usePalette('default') + const store = useStores() + const suggestedActorsView = React.useMemo( + () => new SuggestedActorsViewModel(store, {pageSize: 5}), + [store], + ) + + React.useEffect(() => { + suggestedActorsView.loadMore(true) + }, [store, suggestedActorsView]) + + const onPressLoadMoreSuggestedActors = () => { + suggestedActorsView.loadMore() + } + return ( + <> + {(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && ( + + Who to follow + + )} + {suggestedActorsView.hasContent && ( + <> + + {suggestedActorsView.suggestions.map(item => ( + + ))} + + {!suggestedActorsView.isLoading && suggestedActorsView.hasMore && ( + + + Show more + + + )} + + )} + {suggestedActorsView.isLoading && ( + + + + )} + + ) +}) + +const styles = StyleSheet.create({ + heading: { + fontWeight: 'bold', + paddingHorizontal: 12, + paddingTop: 16, + paddingBottom: 8, + }, + + bottomBorder: { + borderBottomWidth: 1, + }, + + loadMore: { + paddingLeft: 16, + paddingVertical: 12, + }, +}) diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index fdaafe737..83259330f 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -86,12 +86,18 @@ function ImageViewing({ [toggleBarsVisible], ) + const onLayout = useCallback(() => { + if (imageIndex) { + imageList.current?.scrollToIndex({index: imageIndex, animated: false}) + } + }, [imageList, imageIndex]) + if (!visible) { return null } return ( - + @@ -108,12 +114,8 @@ function ImageViewing({ data={images} horizontal pagingEnabled - windowSize={2} - initialNumToRender={1} - maxToRenderPerBatch={1} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} - initialScrollIndex={imageIndex} getItem={(_, index) => images[index]} getItemCount={() => images.length} getItemLayout={(_, index) => ({ diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index df5839783..894c6b118 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -2,9 +2,9 @@ import React from 'react' import {View} from 'react-native' import {observer} from 'mobx-react-lite' import ImageView from './ImageViewing' -import {useStores} from '../../../state' -import * as models from '../../../state/models/shell-ui' -import {saveImageModal} from '../../../lib/images' +import {useStores} from 'state/index' +import * as models from 'state/models/shell-ui' +import {saveImageModal} from 'lib/images' import {ImageSource} from './ImageViewing/@types' export const Lightbox = observer(function Lightbox() { diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 4062ddef7..dfe4f5603 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -8,9 +8,9 @@ import { } from 'react-native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useStores} from '../../../state' -import * as models from '../../../state/models/shell-ui' -import {colors} from '../../lib/styles' +import {useStores} from 'state/index' +import * as models from 'state/models/shell-ui' +import {colors} from 'lib/styles' interface Img { uri: string diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx index dbb3ab52f..6dc93f5e3 100644 --- a/src/view/com/login/CreateAccount.tsx +++ b/src/view/com/login/CreateAccount.tsx @@ -15,24 +15,22 @@ import { } from '@fortawesome/react-native-fontawesome' import {ComAtprotoAccountCreate} from '@atproto/api' import * as EmailValidator from 'email-validator' -// import {useAnalytics} from '@segment/analytics-react-native' TODO +import {useAnalytics} from 'lib/analytics' import {LogoTextHero} from './Logo' import {Picker} from '../util/Picker' import {TextLink} from '../util/Link' import {Text} from '../util/text/Text' -import {s, colors} from '../../lib/styles' -import { - makeValidHandle, - createFullHandle, - toNiceDomain, -} from '../../../lib/strings' -import {useStores, DEFAULT_SERVICE} from '../../../state' -import {ServiceDescription} from '../../../state/models/session' -import {ServerInputModal} from '../../../state/models/shell-ui' -import {usePalette} from '../../lib/hooks/usePalette' +import {s, colors} from 'lib/styles' +import {makeValidHandle, createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {useStores, DEFAULT_SERVICE} from 'state/index' +import {ServiceDescription} from 'state/models/session' +import {ServerInputModal} from 'state/models/shell-ui' +import {usePalette} from 'lib/hooks/usePalette' +import {cleanError} from 'lib/strings/errors' export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { - // const {track} = useAnalytics() TODO + const {track, screen} = useAnalytics() const pal = usePalette('default') const store = useStores() const [isProcessing, setIsProcessing] = useState(false) @@ -49,6 +47,10 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { const [handle, setHandle] = useState('') const [is13, setIs13] = useState(false) + useEffect(() => { + screen('CreateAccount') + }, [screen]) + useEffect(() => { let aborted = false setError('') @@ -109,7 +111,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { password, inviteCode, }) - // track('Create Account') TODO + track('Create Account') } catch (e: any) { let errMsg = e.toString() if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { @@ -118,7 +120,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { } store.log.error('Failed to create account', e) setIsProcessing(false) - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } diff --git a/src/view/com/login/Logo.tsx b/src/view/com/login/Logo.tsx index ec53549b1..7601ea31f 100644 --- a/src/view/com/login/Logo.tsx +++ b/src/view/com/login/Logo.tsx @@ -1,7 +1,7 @@ import React from 'react' import {StyleSheet} from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import {s, gradients} from '../../lib/styles' +import {s, gradients} from 'lib/styles' import {Text} from '../util/text/Text' export const LogoTextHero = () => { diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index 8a260a9ba..eed173119 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -13,19 +13,21 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import * as EmailValidator from 'email-validator' -import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api' -// import {useAnalytics} from '@segment/analytics-react-native' TODO +import AtpAgent from '@atproto/api' +import {useAnalytics} from 'lib/analytics' import {LogoTextHero} from './Logo' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import {s, colors} from '../../lib/styles' -import {createFullHandle, toNiceDomain} from '../../../lib/strings' -import {useStores, RootStoreModel, DEFAULT_SERVICE} from '../../../state' -import {ServiceDescription} from '../../../state/models/session' -import {ServerInputModal} from '../../../state/models/shell-ui' -import {AccountData} from '../../../state/models/session' -import {isNetworkError} from '../../../lib/errors' -import {usePalette} from '../../lib/hooks/usePalette' +import {s, colors} from 'lib/styles' +import {createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index' +import {ServiceDescription} from 'state/models/session' +import {ServerInputModal} from 'state/models/shell-ui' +import {AccountData} from 'state/models/session' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {cleanError} from 'lib/strings/errors' enum Forms { Login, @@ -38,6 +40,7 @@ enum Forms { export const Signin = ({onPressBack}: {onPressBack: () => void}) => { const pal = usePalette('default') const store = useStores() + const {track} = useAnalytics() const [error, setError] = useState('') const [retryDescribeTrigger, setRetryDescribeTrigger] = useState({}) const [serviceUrl, setServiceUrl] = useState(DEFAULT_SERVICE) @@ -91,6 +94,10 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => { }, [store.session, store.log, serviceUrl, retryDescribeTrigger]) const onPressRetryConnect = () => setRetryDescribeTrigger({}) + const onPressForgotPassword = () => { + track('Signin:PressedForgotPassword') + setCurrentForm(Forms.ForgotPassword) + } return ( @@ -104,7 +111,7 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => { setError={setError} setServiceUrl={setServiceUrl} onPressBack={onPressBack} - onPressForgotPassword={gotoForm(Forms.ForgotPassword)} + onPressForgotPassword={onPressForgotPassword} onPressRetryConnect={onPressRetryConnect} /> ) : undefined} @@ -153,15 +160,19 @@ const ChooseAccountForm = ({ onSelectAccount: (account?: AccountData) => void onPressBack: () => void }) => { - // const {track} = useAnalytics() TODO + const {track, screen} = useAnalytics() const pal = usePalette('default') const [isProcessing, setIsProcessing] = React.useState(false) + // React.useEffect(() => { + screen('Choose Account') + // }, [screen]) + const onTryAccount = async (account: AccountData) => { if (account.accessJwt && account.refreshJwt) { setIsProcessing(true) if (await store.session.resumeSession(account)) { - // track('Sign In', {resumedSession: true}) TODO + track('Sign In', {resumedSession: true}) setIsProcessing(false) return } @@ -261,15 +272,16 @@ const LoginForm = ({ onPressBack: () => void onPressForgotPassword: () => void }) => { - // const {track} = useAnalytics() TODO + const {track} = useAnalytics() const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState(false) - const [handle, setHandle] = useState(initialHandle) + const [identifier, setIdentifier] = useState(initialHandle) const [password, setPassword] = useState('') const onPressSelectService = () => { store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) Keyboard.dismiss() + track('Signin:PressedSelectService') } const onPressNext = async () => { @@ -278,20 +290,21 @@ const LoginForm = ({ try { // try to guess the handle if the user just gave their own username - let fullHandle = handle + let fullIdent = identifier if ( + !identifier.includes('@') && // not an email serviceDescription && serviceDescription.availableUserDomains.length > 0 ) { let matched = false for (const domain of serviceDescription.availableUserDomains) { - if (fullHandle.endsWith(domain)) { + if (fullIdent.endsWith(domain)) { matched = true } } if (!matched) { - fullHandle = createFullHandle( - handle, + fullIdent = createFullHandle( + identifier, serviceDescription.availableUserDomains[0], ) } @@ -299,10 +312,10 @@ const LoginForm = ({ await store.session.login({ service: serviceUrl, - handle: fullHandle, + identifier: fullIdent, password, }) - // track('Sign In', {resumedSession: false}) TODO + track('Sign In', {resumedSession: false}) } catch (e: any) { const errMsg = e.toString() store.log.warn('Failed to login', e) @@ -314,12 +327,12 @@ const LoginForm = ({ 'Unable to contact your service. Please check your Internet connection.', ) } else { - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } } - const isReady = !!serviceDescription && !!handle && !!password + const isReady = !!serviceDescription && !!identifier && !!password return ( @@ -361,13 +374,13 @@ const LoginForm = ({ setHandle((str || '').toLowerCase())} + value={identifier} + onChangeText={str => setIdentifier((str || '').toLowerCase())} editable={!isProcessing} /> @@ -464,6 +477,11 @@ const ForgotPasswordForm = ({ const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState(false) const [email, setEmail] = useState('') + const {screen} = useAnalytics() + + // useEffect(() => { + screen('Signin:ForgotPassword') + // }, [screen]) const onPressSelectService = () => { store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) @@ -478,8 +496,8 @@ const ForgotPasswordForm = ({ setIsProcessing(true) try { - const api = AtpApi.service(serviceUrl) as SessionServiceClient - await api.com.atproto.account.requestPasswordReset({email}) + const agent = new AtpAgent({service: serviceUrl}) + await agent.api.com.atproto.account.requestPasswordReset({email}) onEmailSent() } catch (e: any) { const errMsg = e.toString() @@ -490,7 +508,7 @@ const ForgotPasswordForm = ({ 'Unable to contact your service. Please check your Internet connection.', ) } else { - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } } @@ -604,6 +622,12 @@ const SetNewPasswordForm = ({ onPasswordSet: () => void }) => { const pal = usePalette('default') + const {screen} = useAnalytics() + + // useEffect(() => { + screen('Signin:SetNewPasswordForm') + // }, [screen]) + const [isProcessing, setIsProcessing] = useState(false) const [resetCode, setResetCode] = useState('') const [password, setPassword] = useState('') @@ -613,8 +637,11 @@ const SetNewPasswordForm = ({ setIsProcessing(true) try { - const api = AtpApi.service(serviceUrl) as SessionServiceClient - await api.com.atproto.account.resetPassword({token: resetCode, password}) + const agent = new AtpAgent({service: serviceUrl}) + await agent.api.com.atproto.account.resetPassword({ + token: resetCode, + password, + }) onPasswordSet() } catch (e: any) { const errMsg = e.toString() @@ -625,7 +652,7 @@ const SetNewPasswordForm = ({ 'Unable to contact your service. Please check your Internet connection.', ) } else { - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } } @@ -726,6 +753,12 @@ const SetNewPasswordForm = ({ } const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { + const {screen} = useAnalytics() + + // useEffect(() => { + screen('Signin:PasswordUpdatedForm') + // }, [screen]) + const pal = usePalette('default') return ( <> diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 3e2ad6eea..60c104f99 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -7,9 +7,10 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors, gradients} from '../../lib/styles' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' export const snapPoints = ['50%'] @@ -33,7 +34,7 @@ export function Component({ store.shell.closeModal() return } catch (e: any) { - setError(e.toString()) + setError(cleanError(e)) setIsProcessing(false) } } diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx new file mode 100644 index 000000000..de29e728d --- /dev/null +++ b/src/view/com/modals/DeleteAccount.tsx @@ -0,0 +1,210 @@ +import React from 'react' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {BottomSheetTextInput} from '@gorhom/bottom-sheet' +import LinearGradient from 'react-native-linear-gradient' +import * as Toast from '../util/Toast' +import {Text} from '../util/text/Text' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' + +export const snapPoints = ['60%'] + +export function Component({}: {}) { + const pal = usePalette('default') + const store = useStores() + const [isEmailSent, setIsEmailSent] = React.useState(false) + const [confirmCode, setConfirmCode] = React.useState('') + const [password, setPassword] = React.useState('') + const [isProcessing, setIsProcessing] = React.useState(false) + const [error, setError] = React.useState('') + const onPressSendEmail = async () => { + setError('') + setIsProcessing(true) + try { + await store.api.com.atproto.account.requestDelete() + setIsEmailSent(true) + } catch (e: any) { + setError(cleanError(e)) + } + setIsProcessing(false) + } + const onPressConfirmDelete = async () => { + setError('') + setIsProcessing(true) + try { + await store.api.com.atproto.account.delete({ + did: store.me.did, + password, + token: confirmCode, + }) + Toast.show('Your account has been deleted') + store.nav.tab.fixedTabReset() + store.session.clear() + store.shell.closeModal() + } catch (e: any) { + setError(cleanError(e)) + } + setIsProcessing(false) + } + const onCancel = () => { + store.shell.closeModal() + } + return ( + + + + Delete account + + {!isEmailSent ? ( + <> + + For security reasons, we'll need to send a confirmation code to + your email. + + {error ? ( + + + + ) : undefined} + {isProcessing ? ( + + + + ) : ( + <> + + + + Send email + + + + + + Cancel + + + + )} + + ) : ( + <> + + Check your inbox for an email with the confirmation code to enter + below: + + + + Please enter your password as well: + + + {error ? ( + + + + ) : undefined} + {isProcessing ? ( + + + + ) : ( + <> + + + Delete my account + + + + + Cancel + + + + )} + + )} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + innerContainer: { + paddingBottom: 20, + }, + title: { + textAlign: 'center', + marginTop: 12, + marginBottom: 12, + }, + description: { + textAlign: 'center', + paddingHorizontal: 22, + marginBottom: 10, + }, + mt20: { + marginTop: 20, + }, + mb20: { + marginBottom: 20, + }, + textInput: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 20, + marginHorizontal: 20, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + marginHorizontal: 20, + }, + evilBtn: { + backgroundColor: colors.red4, + }, +}) diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 12b72a399..add75e89e 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -11,18 +11,17 @@ import {ScrollView, TextInput} from './util' import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from '../../../state' -import {ProfileViewModel} from '../../../state/models/profile-view' -import {s, colors, gradients} from '../../lib/styles' -import { - enforceLen, - MAX_DISPLAY_NAME, - MAX_DESCRIPTION, -} from '../../../lib/strings' -import {isNetworkError} from '../../../lib/errors' -import {compressIfNeeded} from '../../../lib/images' +import {useStores} from 'state/index' +import {ProfileViewModel} from 'state/models/profile-view' +import {s, colors, gradients} from 'lib/styles' +import {enforceLen} from 'lib/strings/helpers' +import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants' +import {compressIfNeeded} from 'lib/images' import {UserBanner} from '../util/UserBanner' import {UserAvatar} from '../util/UserAvatar' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {cleanError, isNetworkError} from 'lib/strings/errors' export const snapPoints = ['80%'] @@ -35,6 +34,9 @@ export function Component({ }) { const store = useStores() const [error, setError] = useState('') + const pal = usePalette('default') + const {track} = useAnalytics() + const [isProcessing, setProcessing] = useState(false) const [displayName, setDisplayName] = useState( profileView.displayName || '', @@ -54,24 +56,27 @@ export function Component({ store.shell.closeModal() } const onSelectNewAvatar = async (img: PickedMedia) => { + track('EditProfile:AvatarSelected') try { - const finalImg = await compressIfNeeded(img, 300000) - setNewUserAvatar(finalImg) + const finalImg = await compressIfNeeded(img, 1000000) + setNewUserAvatar({mediaType: 'photo', ...finalImg}) setUserAvatar(finalImg.path) } catch (e: any) { - setError(e.message || e.toString()) + setError(cleanError(e)) } } const onSelectNewBanner = async (img: PickedMedia) => { + track('EditProfile:BannerSelected') try { - const finalImg = await compressIfNeeded(img, 500000) - setNewUserBanner(finalImg) + const finalImg = await compressIfNeeded(img, 1000000) + setNewUserBanner({mediaType: 'photo', ...finalImg}) setUserBanner(finalImg.path) } catch (e: any) { - setError(e.message || e.toString()) + setError(cleanError(e)) } } const onPressSave = async () => { + track('EditProfile:Save') setProcessing(true) if (error) { setError('') @@ -94,7 +99,7 @@ export function Component({ 'Failed to save your profile. Check your internet connection and try again.', ) } else { - setError(e.message) + setError(cleanError(e)) } } setProcessing(false) @@ -103,13 +108,13 @@ export function Component({ return ( - Edit my profile + Edit my profile - + Display Name - Description + Description - Cancel + Cancel diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index d0b40d56d..2529d0d5b 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -2,23 +2,26 @@ import React, {useRef, useEffect} from 'react' import {View} from 'react-native' import {observer} from 'mobx-react-lite' import BottomSheet from '@gorhom/bottom-sheet' -import {useStores} from '../../../state' +import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' -import * as models from '../../../state/models/shell-ui' +import * as models from 'state/models/shell-ui' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ServerInputModal from './ServerInput' import * as ReportPostModal from './ReportPost' import * as ReportAccountModal from './ReportAccount' +import * as DeleteAccountModal from './DeleteAccount' +import {usePalette} from 'lib/hooks/usePalette' +import {StyleSheet} from 'react-native' const CLOSED_SNAPPOINTS = ['10%'] export const Modal = observer(function Modal() { const store = useStores() const bottomSheetRef = useRef(null) - + const pal = usePalette('default') const onBottomSheetChange = (snapPoint: number) => { if (snapPoint === -1) { store.shell.closeModal() @@ -62,10 +65,21 @@ export const Modal = observer(function Modal() { ) } else if (store.shell.activeModal?.name === 'report-post') { snapPoints = ReportPostModal.snapPoints - element = + element = ( + + ) } else if (store.shell.activeModal?.name === 'report-account') { snapPoints = ReportAccountModal.snapPoints - element = + element = ( + + ) + } else if (store.shell.activeModal?.name === 'delete-account') { + snapPoints = DeleteAccountModal.snapPoints + element = } else { element = } @@ -80,8 +94,17 @@ export const Modal = observer(function Modal() { backdropComponent={ store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined } + handleIndicatorStyle={{backgroundColor: pal.text.color}} + handleStyle={[styles.handle, pal.view]} onChange={onBottomSheetChange}> {element} ) }) + +const styles = StyleSheet.create({ + handle: { + borderTopLeftRadius: 10, + borderTopRightRadius: 10, + }, +}) diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 44ea95f07..3c6551093 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -1,10 +1,10 @@ import React from 'react' import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' -import * as models from '../../../state/models/shell-ui' +import * as models from 'state/models/shell-ui' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' @@ -48,9 +48,17 @@ export const Modal = observer(function Modal() { /> ) } else if (store.shell.activeModal?.name === 'report-post') { - element = + element = ( + + ) } else if (store.shell.activeModal?.name === 'report-account') { - element = + element = ( + + ) } else if (store.shell.activeModal?.name === 'crop-image') { element = ( (false) const [error, setError] = useState('') @@ -28,13 +31,30 @@ export function Component() { const onSelectIssue = (v: string) => setIssue(v) const onPress = async () => { setError('') + if (!issue) { + return + } setIsProcessing(true) try { - // TODO + // NOTE: we should update the lexicon of reasontype to include more options -prf + let reasonType = ComAtprotoReportReasonType.OTHER + if (issue === 'spam') { + reasonType = ComAtprotoReportReasonType.SPAM + } + const reason = ITEMS.find(item => item.key === issue)?.label || '' + await store.api.com.atproto.report.create({ + reasonType, + reason, + subject: { + $type: 'com.atproto.repo.repoRef', + did, + }, + }) + Toast.show("Thank you for your report! We'll look into it promptly.") store.shell.closeModal() return } catch (e: any) { - setError(e.toString()) + setError(cleanError(e)) setIsProcessing(false) } } diff --git a/src/view/com/modals/ReportPost.tsx b/src/view/com/modals/ReportPost.tsx index 8a3a1f758..3d47e7ef0 100644 --- a/src/view/com/modals/ReportPost.tsx +++ b/src/view/com/modals/ReportPost.tsx @@ -5,12 +5,15 @@ import { TouchableOpacity, View, } from 'react-native' +import {ComAtprotoReportReasonType} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from '../../../state' -import {s, colors, gradients} from '../../lib/styles' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup' import {Text} from '../util/text/Text' +import * as Toast from '../util/Toast' import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' const ITEMS: RadioGroupItem[] = [ {key: 'spam', label: 'Spam or excessive repeat posts'}, @@ -21,7 +24,13 @@ const ITEMS: RadioGroupItem[] = [ export const snapPoints = ['50%'] -export function Component() { +export function Component({ + postUri, + postCid, +}: { + postUri: string + postCid: string +}) { const store = useStores() const [isProcessing, setIsProcessing] = useState(false) const [error, setError] = useState('') @@ -29,13 +38,31 @@ export function Component() { const onSelectIssue = (v: string) => setIssue(v) const onPress = async () => { setError('') + if (!issue) { + return + } setIsProcessing(true) try { - // TODO + // NOTE: we should update the lexicon of reasontype to include more options -prf + let reasonType = ComAtprotoReportReasonType.OTHER + if (issue === 'spam') { + reasonType = ComAtprotoReportReasonType.SPAM + } + const reason = ITEMS.find(item => item.key === issue)?.label || '' + await store.api.com.atproto.report.create({ + reasonType, + reason, + subject: { + $type: 'com.atproto.repo.recordRef', + uri: postUri, + cid: postCid, + }, + }) + Toast.show("Thank you for your report! We'll look into it promptly.") store.shell.closeModal() return } catch (e: any) { - setError(e.toString()) + setError(cleanError(e)) setIsProcessing(false) } } diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index dde836719..5a9a4cfed 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -6,14 +6,10 @@ import { } from '@fortawesome/react-native-fontawesome' import {ScrollView, TextInput} from './util' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import { - LOCAL_DEV_SERVICE, - STAGING_SERVICE, - PROD_SERVICE, -} from '../../../state/index' -import {LOGIN_INCLUDE_DEV_SERVERS} from '../../../build-flags' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' export const snapPoints = ['80%'] @@ -37,6 +33,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { {LOGIN_INCLUDE_DEV_SERVERS ? ( <> doSelect(LOCAL_DEV_SERVICE)}> Local dev server diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index 1f234c4a6..e43f37397 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -5,10 +5,10 @@ import {Slider} from '@miblanchard/react-native-slider' import LinearGradient from 'react-native-linear-gradient' import {Text} from '../../util/text/Text' import {PickedMedia} from '../../util/images/image-crop-picker/types' -import {s, gradients} from '../../../lib/styles' -import {useStores} from '../../../../state' -import {usePalette} from '../../../lib/hooks/usePalette' -import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons' +import {s, gradients} from 'lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons' enum AspectRatio { Square = 'square', diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 55e958c2b..5c1ee935b 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -1,31 +1,61 @@ -import React from 'react' +import React, {MutableRefObject} from 'react' import {observer} from 'mobx-react-lite' -import {StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import {NotificationsViewModel} from '../../../state/models/notifications-view' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {NotificationsViewModel} from 'state/models/notifications-view' import {FeedItem} from './FeedItem' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {EmptyState} from '../util/EmptyState' -import {OnScrollCb} from '../../lib/hooks/useOnMainScroll' -import {s} from '../../lib/styles' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} export const Feed = observer(function Feed({ view, + scrollElRef, onPressTryAgain, onScroll, }: { view: NotificationsViewModel + scrollElRef?: MutableRefObject | null> onPressTryAgain?: () => void onScroll?: OnScrollCb }) { + const data = React.useMemo(() => { + let feedItems + if (view.hasLoaded) { + if (view.isEmpty) { + feedItems = [EMPTY_FEED_ITEM] + } else { + feedItems = view.notifications + } + } + return feedItems + }, [view.hasLoaded, view.isEmpty, view.notifications]) + + const onRefresh = React.useCallback(async () => { + try { + await view.refresh() + await view.markAllRead() + } catch (err) { + view.rootStore.log.error('Failed to refresh notifications feed', err) + } + }, [view]) + const onEndReached = React.useCallback(async () => { + try { + await view.loadMore() + } catch (err) { + view.rootStore.log.error('Failed to load more notifications', err) + } + }, [view]) + // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your // renderItem function renders components that follow React performance best practices // like PureComponent, shouldComponentUpdate, etc - const renderItem = ({item}: {item: any}) => { + const renderItem = React.useCallback(({item}: {item: any}) => { if (item === EMPTY_FEED_ITEM) { return ( - } - const onRefresh = () => { - view - .refresh() - .catch(err => - view.rootStore.log.error('Failed to refresh notifications feed', err), - ) - } - const onEndReached = () => { - view - .loadMore() - .catch(err => - view.rootStore.log.error('Failed to load more notifications', err), - ) - } - let data - if (view.hasLoaded) { - if (view.isEmpty) { - data = [EMPTY_FEED_ITEM] - } else { - data = view.notifications - } - } + }, []) + + const FeedFooter = React.useCallback( + () => + view.isLoading ? ( + + + + ) : ( + + ), + [view], + ) + return ( @@ -72,9 +93,11 @@ export const Feed = observer(function Feed({ {data && ( item._reactKey} renderItem={renderItem} + ListFooterComponent={FeedFooter} refreshing={view.isRefreshing} onRefresh={onRefresh} onEndReached={onEndReached} @@ -87,5 +110,6 @@ export const Feed = observer(function Feed({ }) const styles = StyleSheet.create({ + feedFooter: {paddingTop: 20}, emptyState: {paddingVertical: 40}, }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index da6fc7d35..94bedc46f 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -14,19 +14,19 @@ import { FontAwesomeIconStyle, Props, } from '@fortawesome/react-native-fontawesome' -import {NotificationsViewItemModel} from '../../../state/models/notifications-view' -import {PostThreadViewModel} from '../../../state/models/post-thread-view' -import {s, colors} from '../../lib/styles' -import {ago, pluralize} from '../../../lib/strings' -import {HeartIconSolid} from '../../lib/icons' +import {NotificationsViewItemModel} from 'state/models/notifications-view' +import {PostThreadViewModel} from 'state/models/post-thread-view' +import {s, colors} from 'lib/styles' +import {ago} from 'lib/strings/time' +import {pluralize} from 'lib/strings/helpers' +import {HeartIconSolid} from 'lib/icons' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import {ImageHorzList} from '../util/images/ImageHorzList' -import {ErrorMessage} from '../util/error/ErrorMessage' import {Post} from '../post/Post' import {Link} from '../util/Link' -import {usePalette} from '../../lib/hooks/usePalette' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' const MAX_AUTHORS = 5 @@ -78,6 +78,10 @@ export const FeedItem = observer(function FeedItem({ } if (item.isReply || item.isMention) { + if (item.additionalPost?.error) { + // hide errors - it doesnt help the user to show them + return + } return ( } - if (additionalPost.error) { - return - } const text = additionalPost.thread?.postRecord.text const images = ( additionalPost.thread.post.embed as AppBskyEmbedImages.Presented diff --git a/src/view/com/onboard/FeatureExplainer.tsx b/src/view/com/onboard/FeatureExplainer.tsx index d8d502cfb..323b1ba14 100644 --- a/src/view/com/onboard/FeatureExplainer.tsx +++ b/src/view/com/onboard/FeatureExplainer.tsx @@ -14,10 +14,10 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {TABS_EXPLAINER} from '../../lib/assets' -import {TABS_ENABLED} from '../../../build-flags' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {TABS_EXPLAINER} from 'lib/assets' +import {TABS_ENABLED} from 'lib/build-flags' const ROUTES = TABS_ENABLED ? [ @@ -127,11 +127,15 @@ export const FeatureExplainer = () => { )} - + Skip - + Next diff --git a/src/view/com/onboard/FeatureExplainer.web.tsx b/src/view/com/onboard/FeatureExplainer.web.tsx index 83d460808..177ac58dd 100644 --- a/src/view/com/onboard/FeatureExplainer.web.tsx +++ b/src/view/com/onboard/FeatureExplainer.web.tsx @@ -2,7 +2,6 @@ import React, {useState} from 'react' import { Animated, Image, - SafeAreaView, StyleSheet, TouchableOpacity, useWindowDimensions, @@ -15,10 +14,10 @@ import { } from '@fortawesome/react-native-fontawesome' import {CenteredView} from '../util/Views.web' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import {TABS_EXPLAINER} from '../../lib/assets' -import {TABS_ENABLED} from '../../../build-flags' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {TABS_EXPLAINER} from 'lib/assets' +import {TABS_ENABLED} from 'lib/build-flags' const ROUTES = TABS_ENABLED ? [ diff --git a/src/view/com/onboard/Follows.tsx b/src/view/com/onboard/Follows.tsx index 76eff3f4b..e7de82b39 100644 --- a/src/view/com/onboard/Follows.tsx +++ b/src/view/com/onboard/Follows.tsx @@ -3,8 +3,8 @@ import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {SuggestedFollows} from '../discover/SuggestedFollows' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' +import {useStores} from 'state/index' +import {s} from 'lib/styles' export const Follows = observer(() => { const store = useStores() @@ -18,13 +18,15 @@ export const Follows = observer(() => { return ( Suggested follows - + + + - + Skip - + Next diff --git a/src/view/com/onboard/Follows.web.tsx b/src/view/com/onboard/Follows.web.tsx index 50e119e49..6b015bb09 100644 --- a/src/view/com/onboard/Follows.web.tsx +++ b/src/view/com/onboard/Follows.web.tsx @@ -1,11 +1,11 @@ import React from 'react' -import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' +import {SafeAreaView, StyleSheet, TouchableOpacity} from 'react-native' import {observer} from 'mobx-react-lite' import {SuggestedFollows} from '../discover/SuggestedFollows' import {CenteredView} from '../util/Views.web' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' +import {useStores} from 'state/index' +import {s} from 'lib/styles' export const Follows = observer(() => { const store = useStores() diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index dacdfa50f..a9fabac3d 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -5,13 +5,10 @@ import {CenteredView, FlatList} from '../util/Views' import { RepostedByViewModel, RepostedByItem, -} from '../../../state/models/reposted-by-view' -import {UserAvatar} from '../util/UserAvatar' +} from 'state/models/reposted-by-view' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ErrorMessage} from '../util/error/ErrorMessage' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' +import {useStores} from 'state/index' export const PostRepostedBy = observer(function PostRepostedBy({ uri, @@ -62,7 +59,15 @@ export const PostRepostedBy = observer(function PostRepostedBy({ // loaded // = const renderItem = ({item}: {item: RepostedByItem}) => ( - + ) return ( { - return ( - - - - - - - {item.displayName || item.handle} - @{item.handle} - - - - ) -} - const styles = StyleSheet.create({ - outer: { - marginTop: 1, - backgroundColor: colors.white, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - avi: { - width: 40, - height: 40, - borderRadius: 20, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 59dbf1e16..0a092c46b 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,14 +1,14 @@ import React, {useRef} from 'react' import {observer} from 'mobx-react-lite' -import {ActivityIndicator, View} from 'react-native' +import {ActivityIndicator} from 'react-native' import {CenteredView, FlatList} from '../util/Views' import { PostThreadViewModel, PostThreadViewPostModel, -} from '../../../state/models/post-thread-view' +} from 'state/models/post-thread-view' import {PostThreadItem} from './PostThreadItem' import {ErrorMessage} from '../util/error/ErrorMessage' -import {s} from '../../lib/styles' +import {s} from 'lib/styles' export const PostThread = observer(function PostThread({ uri, @@ -18,15 +18,24 @@ export const PostThread = observer(function PostThread({ view: PostThreadViewModel }) { const ref = useRef(null) - const posts = view.thread ? Array.from(flattenThread(view.thread)) : [] - const onRefresh = () => { - view - ?.refresh() - .catch(err => - view.rootStore.log.error('Failed to refresh posts thread', err), - ) - } - const onLayout = () => { + const [isRefreshing, setIsRefreshing] = React.useState(false) + const posts = React.useMemo( + () => (view.thread ? Array.from(flattenThread(view.thread)) : []), + [view.thread], + ) + + // events + // = + const onRefresh = React.useCallback(async () => { + setIsRefreshing(true) + try { + view?.refresh() + } catch (err) { + view.rootStore.log.error('Failed to refresh posts thread', err) + } + setIsRefreshing(false) + }, [view, setIsRefreshing]) + const onLayout = React.useCallback(() => { const index = posts.findIndex(post => post._isHighlightedPost) if (index !== -1) { ref.current?.scrollToIndex({ @@ -35,17 +44,20 @@ export const PostThread = observer(function PostThread({ viewOffset: 40, }) } - } - const onScrollToIndexFailed = (info: { - index: number - highestMeasuredFrameIndex: number - averageItemLength: number - }) => { - ref.current?.scrollToOffset({ - animated: false, - offset: info.averageItemLength * info.index, - }) - } + }, [posts, ref]) + const onScrollToIndexFailed = React.useCallback( + (info: { + index: number + highestMeasuredFrameIndex: number + averageItemLength: number + }) => { + ref.current?.scrollToOffset({ + animated: false, + offset: info.averageItemLength * info.index, + }) + }, + [ref], + ) // loading // = @@ -76,9 +88,10 @@ export const PostThread = observer(function PostThread({ item._reactKey} renderItem={renderItem} - refreshing={view.isRefreshing} + refreshing={isRefreshing} onRefresh={onRefresh} onLayout={onLayout} onScrollToIndexFailed={onScrollToIndexFailed} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index d39296285..cd3a49d64 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useState} from 'react' +import React from 'react' import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' @@ -7,22 +7,23 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {PostThreadViewPostModel} from '../../../state/models/post-thread-view' +import {PostThreadViewPostModel} from 'state/models/post-thread-view' import {Link} from '../util/Link' import {RichText} from '../util/text/RichText' import {Text} from '../util/text/Text' import {PostDropdownBtn} from '../util/forms/DropdownButton' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' -import {s} from '../../lib/styles' -import {ago, pluralize} from '../../../lib/strings' -import {useStores} from '../../../state' +import {s} from 'lib/styles' +import {ago} from 'lib/strings/time' +import {pluralize} from 'lib/strings/helpers' +import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/PostEmbeds' import {PostCtrls} from '../util/PostCtrls' import {ErrorMessage} from '../util/error/ErrorMessage' import {ComposePrompt} from '../composer/Prompt' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' const PARENT_REPLY_LINE_LENGTH = 8 @@ -35,29 +36,31 @@ export const PostThreadItem = observer(function PostThreadItem({ }) { const pal = usePalette('default') const store = useStores() - const [deleted, setDeleted] = useState(false) + const [deleted, setDeleted] = React.useState(false) const record = item.postRecord const hasEngagement = item.post.upvoteCount || item.post.repostCount - const itemHref = useMemo(() => { + const itemUri = item.post.uri + const itemCid = item.post.cid + const itemHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}` }, [item.post.uri, item.post.author.handle]) const itemTitle = `Post by ${item.post.author.handle}` const authorHref = `/profile/${item.post.author.handle}` const authorTitle = item.post.author.handle - const upvotesHref = useMemo(() => { + const upvotesHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by` }, [item.post.uri, item.post.author.handle]) const upvotesTitle = 'Likes on this post' - const repostsHref = useMemo(() => { + const repostsHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by` }, [item.post.uri, item.post.author.handle]) const repostsTitle = 'Reposts of this post' - const onPressReply = () => { + const onPressReply = React.useCallback(() => { store.shell.openComposer({ replyTo: { uri: item.post.uri, @@ -71,22 +74,22 @@ export const PostThreadItem = observer(function PostThreadItem({ }, onPost: onPostReply, }) - } - const onPressToggleRepost = () => { - item + }, [store, item, record, onPostReply]) + const onPressToggleRepost = React.useCallback(() => { + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) - } - const onPressToggleUpvote = () => { - item + }, [item, store]) + const onPressToggleUpvote = React.useCallback(() => { + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) - } - const onCopyPostText = () => { + }, [item, store]) + const onCopyPostText = React.useCallback(() => { Clipboard.setString(record?.text || '') Toast.show('Copied to clipboard') - } - const onDeletePost = () => { + }, [record]) + const onDeletePost = React.useCallback(() => { item.delete().then( () => { setDeleted(true) @@ -97,7 +100,7 @@ export const PostThreadItem = observer(function PostThreadItem({ Toast.show('Failed to delete post, please try again') }, ) - } + }, [item, store]) if (!record) { return @@ -154,6 +157,8 @@ export const PostThreadItem = observer(function PostThreadItem({ - {record.text ? ( + {item.richText?.text ? ( @@ -233,6 +237,8 @@ export const PostThreadItem = observer(function PostThreadItem({ This post is by a muted account. - ) : record.text ? ( + ) : item.richText?.text ? ( @@ -314,6 +319,8 @@ export const PostThreadItem = observer(function PostThreadItem({ ) : undefined} - Load more + Continue thread... + ) : undefined} @@ -433,8 +445,12 @@ const styles = StyleSheet.create({ marginRight: 10, }, loadMore: { + flexDirection: 'row', + justifyContent: 'space-between', borderTopWidth: 1, - paddingLeft: 28, + paddingLeft: 80, + paddingRight: 20, paddingVertical: 10, + marginBottom: 8, }, }) diff --git a/src/view/com/post-thread/PostVotedBy.tsx b/src/view/com/post-thread/PostVotedBy.tsx index 680bbadf4..2734aaea9 100644 --- a/src/view/com/post-thread/PostVotedBy.tsx +++ b/src/view/com/post-thread/PostVotedBy.tsx @@ -2,14 +2,10 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import {VotesViewModel, VoteItem} from '../../../state/models/votes-view' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' +import {VotesViewModel, VoteItem} from 'state/models/votes-view' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {useStores} from 'state/index' export const PostVotedBy = observer(function PostVotedBy({ uri, @@ -57,7 +53,17 @@ export const PostVotedBy = observer(function PostVotedBy({ // loaded // = - const renderItem = ({item}: {item: VoteItem}) => + const renderItem = ({item}: {item: VoteItem}) => ( + + ) return ( { - const pal = usePalette('default') - - return ( - - - - - - - - {item.actor.displayName || item.actor.handle} - - - @{item.actor.handle} - - - - - ) -} - const styles = StyleSheet.create({ - outer: { - marginTop: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - avi: { - width: 40, - height: 40, - borderRadius: 20, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index d00cc83c2..8e793ecc0 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -10,7 +10,7 @@ import {observer} from 'mobx-react-lite' import Clipboard from '@react-native-clipboard/clipboard' import {AtUri} from '../../../third-party/uri' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {PostThreadViewModel} from '../../../state/models/post-thread-view' +import {PostThreadViewModel} from 'state/models/post-thread-view' import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' @@ -20,9 +20,9 @@ import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' export const Post = observer(function Post({ uri, @@ -80,6 +80,8 @@ export const Post = observer(function Post({ const item = view.thread const record = view.thread.postRecord + const itemUri = item.post.uri + const itemCid = item.post.cid const itemUrip = new AtUri(item.post.uri) const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` const itemTitle = `Post by ${item.post.author.handle}` @@ -105,12 +107,12 @@ export const Post = observer(function Post({ }) } const onPressToggleRepost = () => { - item + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } const onPressToggleUpvote = () => { - item + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) } @@ -178,18 +180,19 @@ export const Post = observer(function Post({ This post is by a muted account. - ) : record.text ? ( + ) : item.richText?.text ? ( ) : undefined} void }) { - const pal = usePalette('default') - return ( - - onPressCompose(false)} - style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}> - - New post - - - onPressCompose(true)} - style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}> - - Share photo - - - - ) + return null } - -const styles = StyleSheet.create({ - container: { - paddingVertical: 12, - paddingHorizontal: 16, - flexDirection: 'row', - alignItems: 'center', - borderTopWidth: 1, - }, - btn: { - paddingVertical: 6, - paddingHorizontal: 14, - borderRadius: 30, - marginRight: 10, - }, -}) diff --git a/src/view/com/posts/ComposerPrompt.web.tsx b/src/view/com/posts/ComposerPrompt.web.tsx index 96c09f0b3..a87653cf8 100644 --- a/src/view/com/posts/ComposerPrompt.web.tsx +++ b/src/view/com/posts/ComposerPrompt.web.tsx @@ -1,8 +1,8 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' -import {s} from '../../lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' export function ComposerPrompt({ onPressCompose, diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 409ce4af2..57363ca51 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -11,103 +11,144 @@ import {CenteredView, FlatList} from '../util/Views' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {EmptyState} from '../util/EmptyState' import {ErrorMessage} from '../util/error/ErrorMessage' -import {FeedModel} from '../../../state/models/feed-view' +import {FeedModel} from 'state/models/feed-view' import {FeedItem} from './FeedItem' import {ComposerPrompt} from './ComposerPrompt' -import {OnScrollCb} from '../../lib/hooks/useOnMainScroll' -import {s} from '../../lib/styles' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' +import {useAnalytics} from 'lib/analytics' const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} +const ERROR_FEED_ITEM = {_reactKey: '__error__'} export const Feed = observer(function Feed({ feed, style, scrollElRef, - onPressCompose, onPressTryAgain, + onPressCompose, onScroll, testID, + headerOffset = 0, }: { feed: FeedModel style?: StyleProp scrollElRef?: MutableRefObject | null> - onPressCompose: (imagesOpen?: boolean) => void onPressTryAgain?: () => void + onPressCompose: (imagesOpen?: boolean) => void onScroll?: OnScrollCb testID?: string + headerOffset?: number }) { + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) + + const data = React.useMemo(() => { + let feedItems: any[] = [] + if (feed.hasLoaded) { + feedItems = feedItems.concat([COMPOSE_PROMPT_ITEM]) + if (feed.hasError) { + feedItems = feedItems.concat([ERROR_FEED_ITEM]) + } + if (feed.isEmpty) { + feedItems = feedItems.concat([EMPTY_FEED_ITEM]) + } else { + feedItems = feedItems.concat(feed.feed) + } + } + return feedItems + }, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.feed]) + + // events + // = + + const onRefresh = React.useCallback(async () => { + track('Feed:onRefresh') + setIsRefreshing(true) + try { + await feed.refresh() + } catch (err) { + feed.rootStore.log.error('Failed to refresh posts feed', err) + } + setIsRefreshing(false) + }, [feed, track, setIsRefreshing]) + const onEndReached = React.useCallback(async () => { + track('Feed:onEndReached') + try { + await feed.loadMore() + } catch (err) { + feed.rootStore.log.error('Failed to load more posts', err) + } + }, [feed, track]) + + // rendering + // = + // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your // renderItem function renders components that follow React performance best practices // like PureComponent, shouldComponentUpdate, etc - const renderItem = ({item}: {item: any}) => { - if (item === COMPOSE_PROMPT_ITEM) { - return - } else if (item === EMPTY_FEED_ITEM) { - return ( - - ) - } else { - return - } - } - const onRefresh = () => { - feed - .refresh() - .catch(err => - feed.rootStore.log.error('Failed to refresh posts feed', err), - ) - } - const onEndReached = () => { - feed - .loadMore() - .catch(err => feed.rootStore.log.error('Failed to load more posts', err)) - } - let data - if (feed.hasLoaded) { - if (feed.isEmpty) { - data = [COMPOSE_PROMPT_ITEM, EMPTY_FEED_ITEM] - } else { - data = [COMPOSE_PROMPT_ITEM].concat(feed.feed) - } - } - const FeedFooter = () => - feed.isLoading ? ( - - - - ) : ( - - ) - return ( - - - {!data && } - {feed.isLoading && !data && } - {feed.hasError && ( + const renderItem = React.useCallback( + ({item}: {item: any}) => { + if (item === COMPOSE_PROMPT_ITEM) { + return + } else if (item === EMPTY_FEED_ITEM) { + return ( + + ) + } else if (item === ERROR_FEED_ITEM) { + return ( - )} - - {feed.hasLoaded && data && ( + ) + } + return + }, + [feed, onPressTryAgain, onPressCompose], + ) + + const FeedFooter = React.useCallback( + () => + feed.isLoading ? ( + + + + ) : ( + + ), + [feed], + ) + + return ( + + {feed.isLoading && data.length === 0 && ( + + + + )} + {data.length > 0 && ( item._reactKey} renderItem={renderItem} ListFooterComponent={FeedFooter} - refreshing={feed.isRefreshing} + refreshing={isRefreshing} contentContainerStyle={s.contentContainer} onScroll={onScroll} onRefresh={onRefresh} onEndReached={onEndReached} + removeClippedSubviews={true} + contentInset={{top: headerOffset}} + contentOffset={{x: 0, y: headerOffset * -1}} + progressViewOffset={headerOffset} /> )} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index cda2ac0b0..67807b14e 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -8,7 +8,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {FeedItemModel} from '../../../state/models/feed-view' +import {FeedItemModel} from 'state/models/feed-view' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' @@ -18,9 +18,10 @@ import {PostEmbeds} from '../util/PostEmbeds' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' -import {s} from '../../lib/styles' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' +import {s} from 'lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' export const FeedItem = observer(function ({ item, @@ -33,8 +34,11 @@ export const FeedItem = observer(function ({ }) { const store = useStores() const pal = usePalette('default') + const {track} = useAnalytics() const [deleted, setDeleted] = useState(false) const record = item.postRecord + const itemUri = item.post.uri + const itemCid = item.post.cid const itemHref = useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}` @@ -50,6 +54,7 @@ export const FeedItem = observer(function ({ }, [record?.reply]) const onPressReply = () => { + track('FeedItem:PostReply') store.shell.openComposer({ replyTo: { uri: item.post.uri, @@ -64,12 +69,14 @@ export const FeedItem = observer(function ({ }) } const onPressToggleRepost = () => { - item + track('FeedItem:PostRepost') + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } const onPressToggleUpvote = () => { - item + track('FeedItem:PostLike') + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) } @@ -78,6 +85,7 @@ export const FeedItem = observer(function ({ Toast.show('Copied to clipboard') } const onDeletePost = () => { + track('FeedItem:PostDelete') item.delete().then( () => { setDeleted(true) @@ -195,12 +203,11 @@ export const FeedItem = observer(function ({ This post is by a muted account. - ) : record.text ? ( + ) : item.richText?.text ? ( @@ -210,6 +217,8 @@ export const FeedItem = observer(function ({ ) : null} JSX.Element - onPressButton?: () => void }) { const pal = usePalette('default') return ( @@ -36,30 +42,117 @@ export function ProfileCard({ /> - + {displayName || handle} - + @{handle} + {isFollowedBy && ( + + + Follows You + + + )} {renderButton ? ( - - - {renderButton()} - - + {renderButton()} ) : undefined} + {description ? ( + + + {description} + + + ) : undefined} ) } +export const ProfileCardWithFollowBtn = observer( + ({ + did, + declarationCid, + handle, + displayName, + avatar, + description, + isFollowedBy, + }: { + did: string + declarationCid: string + handle: string + displayName?: string + avatar?: string + description?: string + isFollowedBy?: boolean + }) => { + const store = useStores() + const isMe = store.me.handle === handle + const isFollowing = store.me.follows.isFollowing(did) + const onToggleFollow = async () => { + if (store.me.follows.isFollowing(did)) { + try { + await apilib.unfollow(store, store.me.follows.getFollowUri(did)) + store.me.follows.removeFollow(did) + } catch (e: any) { + store.log.error('Failed fo delete follow', e) + Toast.show('An issue occurred, please try again.') + } + } else { + try { + const res = await apilib.follow(store, did, declarationCid) + store.me.follows.addFollow(did, res.uri) + } catch (e: any) { + store.log.error('Failed fo create follow', e) + Toast.show('An issue occurred, please try again.') + } + } + } + return ( + ( + + ) + } + /> + ) + }, +) + +function FollowBtn({ + isFollowing, + onPress, +}: { + isFollowing: boolean + onPress: () => void +}) { + const pal = usePalette('default') + return ( + + + + {isFollowing ? 'Unfollow' : 'Follow'} + + + + ) +} + const styles = StyleSheet.create({ outer: { borderTopWidth: 1, + paddingHorizontal: 6, }, layout: { flexDirection: 'row', @@ -68,7 +161,7 @@ const styles = StyleSheet.create({ layoutAvi: { width: 60, paddingLeft: 10, - paddingTop: 10, + paddingTop: 8, paddingBottom: 10, }, avi: { @@ -80,19 +173,26 @@ const styles = StyleSheet.create({ layoutContent: { flex: 1, paddingRight: 10, - paddingTop: 12, + paddingTop: 10, paddingBottom: 10, }, layoutButton: { paddingRight: 10, }, + details: { + paddingLeft: 60, + paddingRight: 10, + paddingBottom: 10, + }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', paddingVertical: 7, - paddingHorizontal: 14, borderRadius: 50, marginLeft: 6, + paddingHorizontal: 14, }, }) diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 13d134c39..7db770e4b 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -4,15 +4,11 @@ import {ActivityIndicator, StyleSheet, View} from 'react-native' import { UserFollowersViewModel, FollowerItem, -} from '../../../state/models/user-followers-view' +} from 'state/models/user-followers-view' import {CenteredView, FlatList} from '../util/Views' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {ProfileCardWithFollowBtn} from './ProfileCard' +import {useStores} from 'state/index' export const ProfileFollowers = observer(function ProfileFollowers({ name, @@ -63,7 +59,15 @@ export const ProfileFollowers = observer(function ProfileFollowers({ // loaded // = const renderItem = ({item}: {item: FollowerItem}) => ( - + ) return ( { - const pal = usePalette('default') - return ( - - - - - - - - {item.displayName || item.handle} - - - @{item.handle} - - - - - ) -} - const styles = StyleSheet.create({ - outer: { - borderTopWidth: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index de2fe3324..fb7f08ed8 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -2,17 +2,10 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import { - UserFollowsViewModel, - FollowItem, -} from '../../../state/models/user-follows-view' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' +import {UserFollowsViewModel, FollowItem} from 'state/models/user-follows-view' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {ProfileCardWithFollowBtn} from './ProfileCard' +import {useStores} from 'state/index' export const ProfileFollows = observer(function ProfileFollows({ name, @@ -63,7 +56,15 @@ export const ProfileFollows = observer(function ProfileFollows({ // loaded // = const renderItem = ({item}: {item: FollowItem}) => ( - + ) return ( { - const pal = usePalette('default') - return ( - - - - - - - - {item.displayName || item.handle} - - - @{item.handle} - - - - - ) -} - const styles = StyleSheet.create({ - outer: { - borderTopWidth: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index cebeea788..0ca6e1e74 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -13,15 +13,16 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {BlurView} from '../util/BlurView' -import {ProfileViewModel} from '../../../state/models/profile-view' -import {useStores} from '../../../state' +import {ProfileViewModel} from 'state/models/profile-view' +import {useStores} from 'state/index' import { EditProfileModal, ReportAccountModal, ProfileImageLightbox, -} from '../../../state/models/shell-ui' -import {pluralize, toShareUrl} from '../../../lib/strings' -import {s, gradients} from '../../lib/styles' +} from 'state/models/shell-ui' +import {pluralize} from 'lib/strings/helpers' +import {toShareUrl} from 'lib/strings/url-helpers' +import {s, gradients} from 'lib/styles' import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' @@ -29,7 +30,8 @@ import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' export const ProfileHeader = observer(function ProfileHeader({ view, @@ -40,7 +42,7 @@ export const ProfileHeader = observer(function ProfileHeader({ }) { const pal = usePalette('default') const store = useStores() - + const {track} = useAnalytics() const onPressBack = () => { store.nav.tab.goBack() } @@ -53,7 +55,7 @@ export const ProfileHeader = observer(function ProfileHeader({ view?.toggleFollowing().then( () => { Toast.show( - `${view.myState.follow ? 'Following' : 'No longer following'} ${ + `${view.viewer.following ? 'Following' : 'No longer following'} ${ view.displayName || view.handle }`, ) @@ -62,18 +64,23 @@ export const ProfileHeader = observer(function ProfileHeader({ ) } const onPressEditProfile = () => { + track('ProfileHeader:EditProfileButtonClicked') store.shell.openModal(new EditProfileModal(view, onRefreshAll)) } const onPressFollowers = () => { + track('ProfileHeader:FollowersButtonClicked') store.nav.navigate(`/profile/${view.handle}/followers`) } const onPressFollows = () => { + track('ProfileHeader:FollowsButtonClicked') store.nav.navigate(`/profile/${view.handle}/follows`) } const onPressShare = () => { + track('ProfileHeader:ShareButtonClicked') Share.share({url: toShareUrl(`/profile/${view.handle}`)}) } const onPressMuteAccount = async () => { + track('ProfileHeader:MuteAccountButtonClicked') try { await view.muteAccount() Toast.show('Account muted') @@ -83,6 +90,7 @@ export const ProfileHeader = observer(function ProfileHeader({ } } const onPressUnmuteAccount = async () => { + track('ProfileHeader:UnmuteAccountButtonClicked') try { await view.unmuteAccount() Toast.show('Account unmuted') @@ -92,6 +100,7 @@ export const ProfileHeader = observer(function ProfileHeader({ } } const onPressReportAccount = () => { + track('ProfileHeader:ReportAccountButtonClicked') store.shell.openModal(new ReportAccountModal(view.did)) } @@ -110,7 +119,7 @@ export const ProfileHeader = observer(function ProfileHeader({ - + {view.displayName || view.handle} @@ -135,8 +144,8 @@ export const ProfileHeader = observer(function ProfileHeader({ let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}] if (!isMe) { dropdownItems.push({ - label: view.myState.muted ? 'Unmute Account' : 'Mute Account', - onPress: view.myState.muted ? onPressUnmuteAccount : onPressMuteAccount, + label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', + onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount, }) dropdownItems.push({ label: 'Report Account', @@ -159,7 +168,7 @@ export const ProfileHeader = observer(function ProfileHeader({ ) : ( <> - {view.myState.follow ? ( + {store.me.follows.isFollowing(view.did) ? ( @@ -206,11 +215,18 @@ export const ProfileHeader = observer(function ProfileHeader({ ) : undefined} - + {view.displayName || view.handle} + {view.viewer.followedBy ? ( + + + Follows you + + + ) : undefined} @{view.handle} @@ -247,22 +263,21 @@ export const ProfileHeader = observer(function ProfileHeader({ - {view.description ? ( + {view.descriptionRichText ? ( ) : undefined} - {view.myState.muted ? ( + {view.viewer.muted ? ( - Account muted. + Account muted ) : undefined} @@ -369,6 +384,12 @@ const styles = StyleSheet.create({ marginBottom: 5, }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + br40: {borderRadius: 40}, br50: {borderRadius: 50}, }) diff --git a/src/view/com/util/BlurView.web.tsx b/src/view/com/util/BlurView.web.tsx index 7e4300c7c..efcf40b9c 100644 --- a/src/view/com/util/BlurView.web.tsx +++ b/src/view/com/util/BlurView.web.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleSheet, View, ViewProps} from 'react-native' -import {addStyle} from '../../lib/addStyle' +import {addStyle} from 'lib/styles' type BlurViewProps = ViewProps & { blurType?: 'dark' | 'light' diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx index 6c5c3f342..2b2c4e657 100644 --- a/src/view/com/util/EmptyState.tsx +++ b/src/view/com/util/EmptyState.tsx @@ -6,8 +6,8 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from './text/Text' -import {UserGroupIcon} from '../../lib/icons' -import {usePalette} from '../../lib/hooks/usePalette' +import {UserGroupIcon} from 'lib/icons' +import {usePalette} from 'lib/hooks/usePalette' export function EmptyState({ icon, diff --git a/src/view/com/util/FAB.tsx b/src/view/com/util/FAB.tsx index 1129f3361..007ca0ee4 100644 --- a/src/view/com/util/FAB.tsx +++ b/src/view/com/util/FAB.tsx @@ -1,41 +1,53 @@ import React from 'react' import {observer} from 'mobx-react-lite' import { + Animated, GestureResponderEvent, StyleSheet, TouchableWithoutFeedback, - View, } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' -import {colors, gradients} from '../../lib/styles' -import {useStores} from '../../../state' +import {colors, gradients} from 'lib/styles' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {useStores} from 'state/index' type OnPress = ((event: GestureResponderEvent) => void) | undefined export const FAB = observer( - ({icon, onPress}: {icon: IconProp; onPress: OnPress}) => { + ({ + testID, + icon, + onPress, + }: { + testID?: string + icon: IconProp + onPress: OnPress + }) => { const store = useStores() + const interp = useAnimatedValue(0) + React.useEffect(() => { + Animated.timing(interp, { + toValue: store.shell.minimalShellMode ? 1 : 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [{translateY: Animated.multiply(interp, 60)}], + } return ( - - + + - + - + ) }, @@ -46,16 +58,10 @@ const styles = StyleSheet.create({ position: 'absolute', zIndex: 1, right: 22, - bottom: 84, + bottom: 94, width: 60, height: 60, borderRadius: 30, - shadowColor: '#000', - shadowOpacity: 0.3, - shadowOffset: {width: 0, height: 1}, - }, - lower: { - bottom: 34, }, inner: { width: 60, @@ -64,5 +70,4 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, - icon: {}, }) diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 1cbb1af83..bdc447937 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -10,9 +10,9 @@ import { ViewStyle, } from 'react-native' import {Text} from './text/Text' -import {TypographyVariant} from '../../lib/ThemeContext' -import {useStores, RootStoreModel} from '../../../state' -import {convertBskyAppUrlIfNeeded} from '../../../lib/strings' +import {TypographyVariant} from 'lib/ThemeContext' +import {useStores, RootStoreModel} from 'state/index' +import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers' export const Link = observer(function Link({ style, @@ -22,17 +22,21 @@ export const Link = observer(function Link({ noFeedback, }: { style?: StyleProp - href: string + href?: string title?: string children?: React.ReactNode noFeedback?: boolean }) { const store = useStores() const onPress = () => { - handleLink(store, href, false) + if (href) { + handleLink(store, href, false) + } } const onLongPress = () => { - handleLink(store, href, true) + if (href) { + handleLink(store, href, true) + } } if (noFeedback) { return ( diff --git a/src/view/com/util/LoadLatestBtn.tsx b/src/view/com/util/LoadLatestBtn.tsx index 43fa97e6f..fd05ecc9c 100644 --- a/src/view/com/util/LoadLatestBtn.tsx +++ b/src/view/com/util/LoadLatestBtn.tsx @@ -4,9 +4,9 @@ import {observer} from 'mobx-react-lite' import LinearGradient from 'react-native-linear-gradient' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {Text} from './text/Text' -import {colors, gradients} from '../../lib/styles' +import {colors, gradients} from 'lib/styles' import {clamp} from 'lodash' -import {useStores} from '../../../state' +import {useStores} from 'state/index' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx index 388927388..182c1ba5d 100644 --- a/src/view/com/util/LoadLatestBtn.web.tsx +++ b/src/view/com/util/LoadLatestBtn.web.tsx @@ -1,7 +1,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity} from 'react-native' import {Text} from './text/Text' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 9bb200d50..9e72640d2 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -1,10 +1,10 @@ import React from 'react' import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {HeartIcon} from '../../lib/icons' -import {s} from '../../lib/styles' -import {useTheme} from '../../lib/ThemeContext' -import {usePalette} from '../../lib/hooks/usePalette' +import {HeartIcon} from 'lib/icons' +import {s} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function LoadingPlaceholder({ width, diff --git a/src/view/com/util/Picker.tsx b/src/view/com/util/Picker.tsx index 208ec0492..9007cb1f0 100644 --- a/src/view/com/util/Picker.tsx +++ b/src/view/com/util/Picker.tsx @@ -16,7 +16,7 @@ import { } from '@fortawesome/react-native-fontawesome' import RootSiblings from 'react-native-root-siblings' import {Text} from './text/Text' -import {colors} from '../../lib/styles' +import {colors} from 'lib/styles' interface PickerItem { value: string diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index fca70b687..e42c5e63b 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -1,6 +1,5 @@ import React from 'react' import { - Animated, StyleProp, StyleSheet, TouchableOpacity, @@ -12,6 +11,11 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import ReactNativeHapticFeedback from 'react-native-haptic-feedback' +// DISABLED see #135 +// import { +// TriggerableAnimated, +// TriggerableAnimatedRef, +// } from './anim/TriggerableAnimated' import {Text} from './text/Text' import {PostDropdownBtn} from './forms/DropdownButton' import { @@ -19,12 +23,13 @@ import { HeartIconSolid, RepostIcon, CommentBottomArrow, -} from '../../lib/icons' -import {s, colors} from '../../lib/styles' -import {useTheme} from '../../lib/ThemeContext' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +} from 'lib/icons' +import {s, colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' interface PostCtrlsOpts { + itemUri: string + itemCid: string itemHref: string itemTitle: string isAuthor: boolean @@ -36,91 +41,110 @@ interface PostCtrlsOpts { isReposted: boolean isUpvoted: boolean onPressReply: () => void - onPressToggleRepost: () => void - onPressToggleUpvote: () => void + onPressToggleRepost: () => Promise + onPressToggleUpvote: () => Promise onCopyPostText: () => void onDeletePost: () => void } -const HITSLOP = {top: 2, left: 2, bottom: 2, right: 2} +const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} -export function PostCtrls(opts: PostCtrlsOpts) { - const theme = useTheme() - const defaultCtrlColor = React.useMemo( - () => ({ - color: theme.palette.default.postCtrl, +// DISABLED see #135 +/* +function ctrlAnimStart(interp: Animated.Value) { + return Animated.sequence([ + Animated.timing(interp, { + toValue: 1, + duration: 250, + useNativeDriver: true, }), - [theme], - ) - const interp1 = useAnimatedValue(0) - const interp2 = useAnimatedValue(0) - - const anim1Style = { - transform: [ - { - scale: interp1.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 4.0], - }), - }, - ], - opacity: interp1.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 0.0], + Animated.delay(50), + Animated.timing(interp, { + toValue: 0, + duration: 20, + useNativeDriver: true, }), - } - const anim2Style = { + ]) +} + +function ctrlAnimStyle(interp: Animated.Value) { + return { transform: [ { - scale: interp2.interpolate({ + scale: interp.interpolate({ inputRange: [0, 1.0], outputRange: [1.0, 4.0], }), }, ], - opacity: interp2.interpolate({ + opacity: interp.interpolate({ inputRange: [0, 1.0], outputRange: [1.0, 0.0], }), } +} +*/ +export function PostCtrls(opts: PostCtrlsOpts) { + const theme = useTheme() + const defaultCtrlColor = React.useMemo( + () => ({ + color: theme.palette.default.postCtrl, + }), + [theme], + ) as StyleProp + const [repostMod, setRepostMod] = React.useState(0) + const [likeMod, setLikeMod] = React.useState(0) + // DISABLED see #135 + // const repostRef = React.useRef(null) + // const likeRef = React.useRef(null) const onPressToggleRepostWrapper = () => { if (!opts.isReposted) { ReactNativeHapticFeedback.trigger('impactMedium') - Animated.sequence([ - Animated.timing(interp1, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - Animated.delay(100), - Animated.timing(interp1, { - toValue: 0, - duration: 20, - useNativeDriver: true, - }), - ]).start() + setRepostMod(1) + opts + .onPressToggleRepost() + .catch(_e => undefined) + .then(() => setRepostMod(0)) + // DISABLED see #135 + // repostRef.current?.trigger( + // {start: ctrlAnimStart, style: ctrlAnimStyle}, + // async () => { + // await opts.onPressToggleRepost().catch(_e => undefined) + // setRepostMod(0) + // }, + // ) + } else { + setRepostMod(-1) + opts + .onPressToggleRepost() + .catch(_e => undefined) + .then(() => setRepostMod(0)) } - opts.onPressToggleRepost() } const onPressToggleUpvoteWrapper = () => { if (!opts.isUpvoted) { ReactNativeHapticFeedback.trigger('impactMedium') - Animated.sequence([ - Animated.timing(interp2, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - Animated.delay(100), - Animated.timing(interp2, { - toValue: 0, - duration: 20, - useNativeDriver: true, - }), - ]).start() + setLikeMod(1) + opts + .onPressToggleUpvote() + .catch(_e => undefined) + .then(() => setLikeMod(0)) + // DISABLED see #135 + // likeRef.current?.trigger( + // {start: ctrlAnimStart, style: ctrlAnimStyle}, + // async () => { + // await opts.onPressToggleUpvote().catch(_e => undefined) + // setLikeMod(0) + // }, + // ) + } else { + setLikeMod(-1) + opts + .onPressToggleUpvote() + .catch(_e => undefined) + .then(() => setLikeMod(0)) } - opts.onPressToggleUpvote() } return ( @@ -147,7 +171,17 @@ export function PostCtrls(opts: PostCtrlsOpts) { hitSlop={HITSLOP} onPress={onPressToggleRepostWrapper} style={styles.ctrl}> - + 0 + ? (styles.ctrlIconReposted as StyleProp) + : defaultCtrlColor + } + strokeWidth={2.4} + size={opts.big ? 24 : 20} + /> + { + undefined /*DISABLED see #135 - + */ + } {typeof opts.repostCount !== 'undefined' ? ( 0 ? [s.bold, s.green3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.repostCount} + {opts.repostCount + repostMod} ) : undefined} @@ -175,8 +210,21 @@ export function PostCtrls(opts: PostCtrlsOpts) { style={styles.ctrl} hitSlop={HITSLOP} onPress={onPressToggleUpvoteWrapper}> - - {opts.isUpvoted ? ( + {opts.isUpvoted || likeMod > 0 ? ( + } + size={opts.big ? 22 : 16} + /> + ) : ( + + )} + { + undefined /*DISABLED see #135 + {opts.isUpvoted || likeMod > 0 ? ( )} - + */ + } {typeof opts.upvoteCount !== 'undefined' ? ( 0 ? [s.bold, s.red3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.upvoteCount} + {opts.upvoteCount + likeMod} ) : undefined} @@ -208,6 +257,8 @@ export function PostCtrls(opts: PostCtrlsOpts) { {opts.big ? undefined : ( -}) { - const pal = usePalette('default') - const store = useStores() - if (AppBskyEmbedImages.isPresented(embed)) { - if (embed.images.length > 0) { - const uris = embed.images.map(img => img.fullsize) - const openLightbox = (index: number) => { - store.shell.openLightbox(new ImagesLightbox(uris, index)) - } - const onLongPress = (index: number) => { - saveImageModal({uri: uris[index]}) - } - - if (embed.images.length === 4) { - return ( - - img.thumb)} - onPress={openLightbox} - onLongPress={onLongPress} - /> - - ) - } else if (embed.images.length === 3) { - return ( - - img.thumb)} - onPress={openLightbox} - onLongPress={onLongPress} - /> - - ) - } else if (embed.images.length === 2) { - return ( - - img.thumb)} - onPress={openLightbox} - onLongPress={onLongPress} - /> - - ) - } else { - return ( - - openLightbox(0)} - onLongPress={() => onLongPress(0)} - containerStyle={styles.singleImage} - /> - - ) - } - } - } - if (AppBskyEmbedExternal.isPresented(embed)) { - const link = embed.external - return ( - - {link.thumb ? ( - - ) : ( - - )} - - - {link.title || link.uri} - - - {link.uri} - - {link.description ? ( - - {link.description} - - ) : undefined} - - - ) - } - return -} - -const styles = StyleSheet.create({ - imagesContainer: { - marginTop: 4, - }, - singleImage: { - borderRadius: 8, - }, - extOuter: { - borderWidth: 1, - borderRadius: 8, - marginTop: 4, - }, - extInner: { - padding: 10, - }, - extImage: { - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - width: '100%', - maxHeight: 200, - }, - extImageFallback: { - height: 160, - }, - extUri: { - marginTop: 2, - }, - extDescription: { - marginTop: 4, - }, -}) diff --git a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx b/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx new file mode 100644 index 000000000..e8c63bdb7 --- /dev/null +++ b/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import {Text} from '../text/Text' +import {AutoSizedImage} from '../images/AutoSizedImage' +import {StyleSheet, View} from 'react-native' +import {usePalette} from 'lib/hooks/usePalette' +import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' + +const ExternalLinkEmbed = ({ + link, + onImagePress, + imageChild, +}: { + link: PresentedExternal + onImagePress?: () => void + imageChild?: React.ReactNode +}) => { + const pal = usePalette('default') + return ( + <> + {link.thumb ? ( + + {imageChild} + + ) : undefined} + + + {link.title || link.uri} + + + {link.uri} + + {link.description ? ( + + {link.description} + + ) : undefined} + + + ) +} + +const styles = StyleSheet.create({ + extInner: { + padding: 10, + }, + extImage: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + width: '100%', + maxHeight: 200, + }, + extUri: { + marginTop: 2, + }, + extDescription: { + marginTop: 4, + }, +}) + +export default ExternalLinkEmbed diff --git a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx new file mode 100644 index 000000000..d9425fe4e --- /dev/null +++ b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx @@ -0,0 +1,119 @@ +import React, {useEffect} from 'react' +import {useState} from 'react' +import { + View, + StyleSheet, + Pressable, + TouchableWithoutFeedback, + EmitterSubscription, +} from 'react-native' +import YoutubePlayer from 'react-native-youtube-iframe' +import {usePalette} from 'lib/hooks/usePalette' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import ExternalLinkEmbed from './ExternalLinkEmbed' +import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' +import {useStores} from 'state/index' + +const YoutubeEmbed = ({ + link, + videoId, +}: { + videoId: string + link: PresentedExternal +}) => { + const store = useStores() + const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false) + const [playerDimensions, setPlayerDimensions] = useState({ + width: 0, + height: 0, + }) + const pal = usePalette('default') + const handlePlayButtonPressed = () => { + setDisplayVideoPlayer(true) + } + const handleOnLayout = (event: { + nativeEvent: {layout: {width: any; height: any}} + }) => { + setPlayerDimensions({ + width: event.nativeEvent.layout.width, + height: event.nativeEvent.layout.height, + }) + } + useEffect(() => { + let sub: EmitterSubscription + if (displayVideoPlayer) { + sub = store.onNavigation(() => { + setDisplayVideoPlayer(false) + }) + } + return () => sub && sub.remove() + }, [displayVideoPlayer, store]) + + const imageChild = ( + + + + ) + + if (!displayVideoPlayer) { + return ( + + + + ) + } + + const height = (playerDimensions.width / 16) * 9 + const noop = () => {} + + return ( + + + {/* Removing the outter View will make tap events propagate to parents */} + + + + ) +} + +const styles = StyleSheet.create({ + extOuter: { + borderWidth: 1, + borderRadius: 8, + marginTop: 4, + }, + playButton: { + position: 'absolute', + alignSelf: 'center', + alignItems: 'center', + top: '44%', + justifyContent: 'center', + backgroundColor: 'black', + padding: 10, + borderRadius: 50, + opacity: 0.8, + }, + webView: { + alignItems: 'center', + alignContent: 'center', + justifyContent: 'center', + }, +}) + +export default YoutubeEmbed diff --git a/src/view/com/util/PostEmbeds/index.tsx b/src/view/com/util/PostEmbeds/index.tsx new file mode 100644 index 000000000..031f01e88 --- /dev/null +++ b/src/view/com/util/PostEmbeds/index.tsx @@ -0,0 +1,139 @@ +import React from 'react' +import { + StyleSheet, + StyleProp, + View, + ViewStyle, + Image as RNImage, +} from 'react-native' +import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' +import {Link} from '../Link' +import {AutoSizedImage} from '../images/AutoSizedImage' +import {ImageLayoutGrid} from '../images/ImageLayoutGrid' +import {ImagesLightbox} from 'state/models/shell-ui' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {saveImageModal} from 'lib/images' +import YoutubeEmbed from './YoutubeEmbed' +import ExternalLinkEmbed from './ExternalLinkEmbed' +import {getYoutubeVideoId} from 'lib/strings/url-helpers' + +type Embed = + | AppBskyEmbedImages.Presented + | AppBskyEmbedExternal.Presented + | {$type: string; [k: string]: unknown} + +export function PostEmbeds({ + embed, + style, +}: { + embed?: Embed + style?: StyleProp +}) { + const pal = usePalette('default') + const store = useStores() + if (AppBskyEmbedImages.isPresented(embed)) { + if (embed.images.length > 0) { + const uris = embed.images.map(img => img.fullsize) + const openLightbox = (index: number) => { + store.shell.openLightbox(new ImagesLightbox(uris, index)) + } + const onLongPress = (index: number) => { + saveImageModal({uri: uris[index]}) + } + const onPressIn = (index: number) => { + const firstImageToShow = uris[index] + RNImage.prefetch(firstImageToShow) + uris.forEach(uri => { + if (firstImageToShow !== uri) { + // First image already prefeched above + RNImage.prefetch(uri) + } + }) + } + + if (embed.images.length === 4) { + return ( + + img.thumb)} + onPress={openLightbox} + onLongPress={onLongPress} + onPressIn={onPressIn} + /> + + ) + } else if (embed.images.length === 3) { + return ( + + img.thumb)} + onPress={openLightbox} + onLongPress={onLongPress} + onPressIn={onPressIn} + /> + + ) + } else if (embed.images.length === 2) { + return ( + + img.thumb)} + onPress={openLightbox} + onLongPress={onLongPress} + onPressIn={onPressIn} + /> + + ) + } else { + return ( + + openLightbox(0)} + onLongPress={() => onLongPress(0)} + onPressIn={() => onPressIn(0)} + style={styles.singleImage} + /> + + ) + } + } + } + if (AppBskyEmbedExternal.isPresented(embed)) { + const link = embed.external + const youtubeVideoId = getYoutubeVideoId(link.uri) + + if (youtubeVideoId) { + return + } + + return ( + + + + ) + } + return +} + +const styles = StyleSheet.create({ + imagesContainer: { + marginTop: 4, + }, + singleImage: { + borderRadius: 8, + maxHeight: 500, + }, + extOuter: { + borderWidth: 1, + borderRadius: 8, + marginTop: 4, + }, +}) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 35edeafb4..16b9535ff 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -1,8 +1,8 @@ import React from 'react' import {Platform, StyleSheet, View} from 'react-native' import {Text} from './text/Text' -import {ago} from '../../../lib/strings' -import {usePalette} from '../../lib/hooks/usePalette' +import {ago} from 'lib/strings/time' +import {usePalette} from 'lib/hooks/usePalette' interface PostMetaOpts { authorHandle: string diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx index 87540cf38..5b331dc8d 100644 --- a/src/view/com/util/Selector.tsx +++ b/src/view/com/util/Selector.tsx @@ -6,7 +6,7 @@ import { View, } from 'react-native' import {Text} from './text/Text' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' interface Layout { x: number diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 287d94412..9b8dd3de5 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -1,15 +1,23 @@ -import React, {useCallback} from 'react' -import {Alert, Image, StyleSheet, TouchableOpacity, View} from 'react-native' +import React from 'react' +import {StyleSheet, View} from 'react-native' import Svg, {Circle, Text, Defs, LinearGradient, Stop} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {HighPriorityImage} from 'view/com/util/images/Image' import { openCamera, openCropper, openPicker, PickedMedia, } from './images/image-crop-picker/ImageCropPicker' -import {useStores} from '../../../state' -import {colors, gradients} from '../../lib/styles' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from 'lib/permissions' +import {useStores} from 'state/index' +import {colors, gradients} from 'lib/styles' +import {DropdownButton} from './forms/DropdownButton' +import {usePalette} from 'lib/hooks/usePalette' export function UserAvatar({ size, @@ -25,40 +33,9 @@ export function UserAvatar({ onSelectNewAvatar?: (img: PickedMedia) => void }) { const store = useStores() + const pal = usePalette('default') const initials = getInitials(displayName || handle) - const handleEditAvatar = useCallback(() => { - Alert.alert('Select upload method', '', [ - { - text: 'Take a new photo', - onPress: () => { - openCamera(store, { - mediaType: 'photo', - width: 1000, - height: 1000, - cropperCircleOverlay: true, - }).then(onSelectNewAvatar) - }, - }, - { - text: 'Select from gallery', - onPress: () => { - openPicker(store, { - mediaType: 'photo', - }).then(async items => { - await openCropper(store, { - mediaType: 'photo', - path: items[0].path, - width: 1000, - height: 1000, - cropperCircleOverlay: true, - }).then(onSelectNewAvatar) - }) - }, - }, - ]) - }, [store, onSelectNewAvatar]) - const renderSvg = (svgSize: number, svgInitials: string) => ( @@ -80,11 +57,65 @@ export function UserAvatar({ ) + const dropdownItems = [ + { + label: 'Camera', + icon: 'camera' as IconProp, + onPress: async () => { + if (!(await requestCameraAccessIfNeeded())) { + return + } + onSelectNewAvatar?.( + await openCamera(store, { + mediaType: 'photo', + width: 1000, + height: 1000, + cropperCircleOverlay: true, + }), + ) + }, + }, + { + label: 'Library', + icon: 'image' as IconProp, + onPress: async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { + mediaType: 'photo', + }) + onSelectNewAvatar?.( + await openCropper(store, { + mediaType: 'photo', + path: items[0].path, + width: 1000, + height: 1000, + cropperCircleOverlay: true, + }), + ) + }, + }, + // TODO: Remove avatar https://github.com/bluesky-social/social-app/issues/122 + // { + // label: 'Remove', + // icon: ['far', 'trash-can'], + // onPress: () => { + // // Remove avatar API call + // }, + // }, + ] // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( - + {avatar ? ( - + - + ) : avatar ? ( - void }) { const store = useStores() - const handleEditBanner = useCallback(() => { - Alert.alert('Select upload method', '', [ - { - text: 'Take a new photo', - onPress: () => { - openCamera(store, { + const pal = usePalette('default') + const dropdownItems = [ + { + label: 'Camera', + icon: 'camera' as IconProp, + onPress: async () => { + if (!(await requestCameraAccessIfNeeded())) { + return + } + onSelectNewBanner?.( + await openCamera(store, { mediaType: 'photo', // compressImageMaxWidth: 3000, TODO needed? width: 3000, // compressImageMaxHeight: 1000, TODO needed? height: 1000, - }).then(onSelectNewBanner) - }, + }), + ) }, - { - text: 'Select from gallery', - onPress: () => { - openPicker(store, { + }, + { + label: 'Library', + icon: 'image' as IconProp, + onPress: async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { + mediaType: 'photo', + }) + onSelectNewBanner?.( + await openCropper(store, { mediaType: 'photo', - }).then(async items => { - await openCropper(store, { - mediaType: 'photo', - path: items[0].path, - // compressImageMaxWidth: 3000, TODO needed? - width: 3000, - // compressImageMaxHeight: 1000, TODO needed? - height: 1000, - }).then(onSelectNewBanner) - }) - }, + path: items[0].path, + // compressImageMaxWidth: 3000, TODO needed? + width: 3000, + // compressImageMaxHeight: 1000, TODO needed? + height: 1000, + }), + ) }, - ]) - }, [store, onSelectNewBanner]) + }, + // TODO: Remove banner https://github.com/bluesky-social/social-app/issues/122 + // { + // label: 'Remove', + // icon: ['far', 'trash-can'], + // onPress: () => { + // // Remove banner api call + // }, + // }, + ] const renderSvg = () => ( @@ -72,20 +98,27 @@ export function UserBanner({ // setUserBanner is only passed as prop on the EditProfile component return onSelectNewBanner ? ( - + {banner ? ( ) : ( renderSvg() )} - + - + ) : banner ? ( { store.nav.tab.goBack() } const onPressMenu = () => { + track('ViewHeader:MenuButtonClicked') store.shell.setMainMenuOpen(true) } - const onPressSearch = () => { - store.nav.navigate('/search') - } - const onPressReconnect = () => { - store.session.connect().catch(e => { - store.log.warn('Failed to reconnect to server', e) - }) - } if (typeof canGoBack === 'undefined') { canGoBack = store.nav.tab.canGoBack } return ( - + {title} - {subtitle ? ( - - {subtitle} - - ) : undefined} - - - - {!store.session.online ? ( - - {store.session.attemptingConnect ? ( - - ) : ( - <> - - - - )} - - ) : undefined} - + + ) }) +const Container = observer( + ({ + children, + hideOnScroll, + }: { + children: React.ReactNode + hideOnScroll: boolean + }) => { + const store = useStores() + const pal = usePalette('default') + const interp = useAnimatedValue(0) + + React.useEffect(() => { + if (store.shell.minimalShellMode) { + Animated.timing(interp, { + toValue: 1, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + } else { + Animated.timing(interp, { + toValue: 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + } + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [{translateY: Animated.multiply(interp, -100)}], + } + + if (!hideOnScroll) { + return {children} + } + return ( + + {children} + + ) + }, +) + const styles = StyleSheet.create({ header: { flexDirection: 'row', @@ -125,20 +118,20 @@ const styles = StyleSheet.create({ paddingTop: 6, paddingBottom: 6, }, + headerFloating: { + position: 'absolute', + top: 0, + width: '100%', + }, titleContainer: { - flexDirection: 'row', - alignItems: 'baseline', + marginLeft: 'auto', marginRight: 'auto', + paddingRight: 10, }, title: { fontWeight: 'bold', }, - subtitle: { - marginLeft: 4, - maxWidth: 200, - fontWeight: 'normal', - }, backBtn: { width: 30, @@ -152,19 +145,4 @@ const styles = StyleSheet.create({ backIcon: { marginTop: 6, }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: 36, - height: 36, - borderRadius: 20, - marginLeft: 4, - }, - littleXIcon: { - color: colors.red3, - position: 'absolute', - right: 7, - bottom: 7, - }, }) diff --git a/src/view/com/util/ViewHeader.web.tsx b/src/view/com/util/ViewHeader.web.tsx index 0d5c99aac..5c0869e8b 100644 --- a/src/view/com/util/ViewHeader.web.tsx +++ b/src/view/com/util/ViewHeader.web.tsx @@ -1,20 +1,12 @@ import React from 'react' import {observer} from 'mobx-react-lite' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {CenteredView} from './Views' import {Text} from './text/Text' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' -import {colors} from '../../lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {colors} from 'lib/styles' const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} @@ -32,11 +24,6 @@ export const ViewHeader = observer(function ViewHeader({ const onPressBack = () => { store.nav.tab.goBack() } - const onPressReconnect = () => { - store.session.connect().catch(e => { - store.log.warn('Failed to reconnect to server', e) - }) - } if (typeof canGoBack === 'undefined') { canGoBack = store.nav.tab.canGoBack } @@ -76,29 +63,6 @@ export const ViewHeader = observer(function ViewHeader({ )} - {!store.session.online ? ( - - {store.session.attemptingConnect ? ( - - ) : ( - <> - - - - )} - - ) : undefined} ) }) diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index ff5115c51..b786c2290 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -3,10 +3,10 @@ import {View} from 'react-native' import {Selector} from './Selector' import {HorzSwipe} from './gestures/HorzSwipe' import {FlatList} from './Views' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' -import {OnScrollCb} from '../../lib/hooks/useOnMainScroll' -import {clamp} from '../../../lib/numbers' -import {s} from '../../lib/styles' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {clamp} from 'lib/numbers' +import {s} from 'lib/styles' const HEADER_ITEM = {_reactKey: '__header__'} const SELECTOR_ITEM = {_reactKey: '__selector__'} @@ -101,6 +101,7 @@ export function ViewSelector({ onRefresh={onRefresh} onEndReached={onEndReached} contentContainerStyle={s.contentContainer} + removeClippedSubviews={true} /> ) diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 2df534144..c16070b2b 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -22,9 +22,8 @@ import { View, ViewProps, } from 'react-native' -import {useTheme} from '../../lib/ThemeContext' -import {addStyle} from '../../lib/addStyle' -import {colors} from '../../lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {addStyle, colors} from 'lib/styles' export function CenteredView({ style, diff --git a/src/view/com/util/anim/TriggerableAnimated.tsx b/src/view/com/util/anim/TriggerableAnimated.tsx new file mode 100644 index 000000000..2a3cbb957 --- /dev/null +++ b/src/view/com/util/anim/TriggerableAnimated.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import {Animated, StyleProp, View, ViewStyle} from 'react-native' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' + +type CreateAnimFn = (interp: Animated.Value) => Animated.CompositeAnimation +type FinishCb = () => void + +interface TriggeredAnimation { + start: CreateAnimFn + style: ( + interp: Animated.Value, + ) => Animated.WithAnimatedValue> +} + +export interface TriggerableAnimatedRef { + trigger: (anim: TriggeredAnimation, onFinish?: FinishCb) => void +} + +type TriggerableAnimatedProps = React.PropsWithChildren<{}> + +type PropsInner = TriggerableAnimatedProps & { + anim: TriggeredAnimation + onFinish: () => void +} + +export const TriggerableAnimated = React.forwardRef< + TriggerableAnimatedRef, + TriggerableAnimatedProps +>(({children, ...props}, ref) => { + const [anim, setAnim] = React.useState( + undefined, + ) + const [finishCb, setFinishCb] = React.useState( + undefined, + ) + React.useImperativeHandle(ref, () => ({ + trigger(v: TriggeredAnimation, cb?: FinishCb) { + setFinishCb(() => cb) // note- wrap in function due to react behaviors around setstate + setAnim(v) + }, + })) + const onFinish = () => { + finishCb?.() + setAnim(undefined) + setFinishCb(undefined) + } + return ( + + {anim ? ( + + {children} + + ) : ( + children + )} + + ) +}) + +function AnimatingView({ + anim, + onFinish, + children, +}: React.PropsWithChildren) { + const interp = useAnimatedValue(0) + React.useEffect(() => { + anim?.start(interp).start(() => { + onFinish() + }) + }) + const animStyle = anim?.style(interp) + return {children} +} diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx index a6d77326e..e6e27fac0 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -11,8 +11,8 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from '../text/Text' -import {useTheme} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function ErrorMessage({ message, diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index f316dbcc6..0221ea153 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -5,9 +5,9 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from '../text/Text' -import {colors} from '../../../lib/styles' -import {useTheme} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' +import {colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function ErrorScreen({ title, diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index b5c4da19d..a070d2f0f 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -7,8 +7,8 @@ import { ViewStyle, } from 'react-native' import {Text} from '../text/Text' -import {useTheme} from '../../../lib/ThemeContext' -import {choose} from '../../../../lib/functions' +import {useTheme} from 'lib/ThemeContext' +import {choose} from 'lib/functions' export type ButtonType = | 'primary' diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index f911529d2..8fddd5941 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -13,11 +13,13 @@ import RootSiblings from 'react-native-root-siblings' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../text/Text' import {Button, ButtonType} from './Button' -import {colors} from '../../../lib/styles' -import {toShareUrl} from '../../../../lib/strings' -import {useStores} from '../../../../state' -import {ReportPostModal, ConfirmModal} from '../../../../state/models/shell-ui' -import {TABS_ENABLED} from '../../../../build-flags' +import {colors} from 'lib/styles' +import {toShareUrl} from 'lib/strings/url-helpers' +import {useStores} from 'state/index' +import {ReportPostModal, ConfirmModal} from 'state/models/shell-ui' +import {TABS_ENABLED} from 'lib/build-flags' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} @@ -36,6 +38,9 @@ export function DropdownButton({ label, menuWidth, children, + openToRight = false, + rightOffset = 0, + bottomOffset = 0, }: { type?: DropdownButtonType style?: StyleProp @@ -43,6 +48,9 @@ export function DropdownButton({ label?: string menuWidth?: number children?: React.ReactNode + openToRight?: boolean + rightOffset?: number + bottomOffset?: number }) { const ref = useRef(null) @@ -59,12 +67,11 @@ export function DropdownButton({ if (!menuWidth) { menuWidth = 200 } - createDropdownMenu( - pageX + width - menuWidth, - pageY + height, - menuWidth, - items, - ) + const newX = openToRight + ? pageX + width + rightOffset + : pageX + width - menuWidth + const newY = pageY + height + bottomOffset + createDropdownMenu(newX, newY, menuWidth, items) }, ) } @@ -97,6 +104,8 @@ export function DropdownButton({ export function PostDropdownBtn({ style, children, + itemUri, + itemCid, itemHref, isAuthor, onCopyPostText, @@ -104,6 +113,8 @@ export function PostDropdownBtn({ }: { style?: StyleProp children?: React.ReactNode + itemUri: string + itemCid: string itemHref: string itemTitle: string isAuthor: boolean @@ -140,7 +151,7 @@ export function PostDropdownBtn({ icon: 'circle-exclamation', label: 'Report post', onPress() { - store.shell.openModal(new ReportPostModal(itemHref)) + store.shell.openModal(new ReportPostModal(itemUri, itemCid)) }, }, isAuthor @@ -180,24 +191,14 @@ function createDropdownMenu( const onOuterPress = () => sibling.destroy() const sibling = new RootSiblings( ( - <> - - - - - {items.map((item, index) => ( - onPressItem(index)}> - {item.icon && ( - - )} - {item.label} - - ))} - - + ), ) return sibling @@ -241,3 +242,55 @@ const styles = StyleSheet.create({ fontSize: 18, }, }) +type DropDownItemProps = { + onOuterPress: () => void + x: number + y: number + width: number + items: DropdownItem[] + onPressItem: (index: number) => void +} + +const DropdownItems = ({ + onOuterPress, + x, + y, + width, + items, + onPressItem, +}: DropDownItemProps) => { + const pal = usePalette('default') + const theme = useTheme() + const dropDownBackgroundColor = + theme.colorScheme === 'dark' ? pal.btn : pal.view + + return ( + <> + + + + + {items.map((item, index) => ( + onPressItem(index)}> + {item.icon && ( + + )} + {item.label} + + ))} + + + ) +} diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index 81489c447..57a875cd3 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -2,8 +2,8 @@ import React from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {Text} from '../text/Text' import {Button, ButtonType} from './Button' -import {useTheme} from '../../../lib/ThemeContext' -import {choose} from '../../../../lib/functions' +import {useTheme} from 'lib/ThemeContext' +import {choose} from 'lib/functions' export function RadioButton({ type = 'default-light', diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx index b33cd9831..901b0cdd8 100644 --- a/src/view/com/util/forms/RadioGroup.tsx +++ b/src/view/com/util/forms/RadioGroup.tsx @@ -2,7 +2,7 @@ import React, {useState} from 'react' import {View} from 'react-native' import {RadioButton} from './RadioButton' import {ButtonType} from './Button' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' export interface RadioGroupItem { label: string diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx index 77e8fa203..005d1165e 100644 --- a/src/view/com/util/forms/ToggleButton.tsx +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -2,9 +2,9 @@ import React from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {Text} from '../text/Text' import {Button, ButtonType} from './Button' -import {useTheme} from '../../../lib/ThemeContext' -import {choose} from '../../../../lib/functions' -import {colors} from '../../../lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {choose} from 'lib/functions' +import {colors} from 'lib/styles' export function ToggleButton({ type = 'default-light', diff --git a/src/view/com/util/gestures/HorzSwipe.tsx b/src/view/com/util/gestures/HorzSwipe.tsx index 22b15afe7..09f6c345f 100644 --- a/src/view/com/util/gestures/HorzSwipe.tsx +++ b/src/view/com/util/gestures/HorzSwipe.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native' import {clamp} from 'lodash' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' interface Props { panX: Animated.Value @@ -90,6 +90,7 @@ export function HorzSwipe({ // swiping right (diffX < 0 && !canSwipeRight) ) { + panX.setValue(0) return } @@ -119,6 +120,7 @@ export function HorzSwipe({ toValue: final, duration: 100, useNativeDriver, + isInteraction: false, }).start(() => { onSwipeEnd?.(final) panX.flattenOffset() @@ -130,6 +132,7 @@ export function HorzSwipe({ toValue: 0, duration: 100, useNativeDriver, + isInteraction: false, }).start(() => { panX.flattenOffset() panX.setValue(0) diff --git a/src/view/com/util/gestures/SwipeAndZoom.tsx b/src/view/com/util/gestures/SwipeAndZoom.tsx index ee00edab7..75c679012 100644 --- a/src/view/com/util/gestures/SwipeAndZoom.tsx +++ b/src/view/com/util/gestures/SwipeAndZoom.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native' import {clamp} from 'lodash' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' export enum Dir { None, diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index cdefc7123..0443c7be4 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -1,125 +1,59 @@ -import React, {useState, useEffect} from 'react' -import { - Image, - ImageStyle, - LayoutChangeEvent, - StyleProp, - StyleSheet, - TouchableOpacity, - View, - ViewStyle, -} from 'react-native' -import {Text} from '../text/Text' -import {useTheme} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' -import {DELAY_PRESS_IN} from './constants' +import React from 'react' +import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native' +import Image, {OnLoadEvent} from 'view/com/util/images/Image' +import {clamp} from 'lib/numbers' -const MAX_HEIGHT = 300 - -interface Dim { - width: number - height: number -} +export const DELAY_PRESS_IN = 500 +const MIN_ASPECT_RATIO = 0.33 // 1/3 +const MAX_ASPECT_RATIO = 5 // 5/1 export function AutoSizedImage({ uri, onPress, onLongPress, + onPressIn, style, - containerStyle, + children = null, }: { uri: string onPress?: () => void onLongPress?: () => void - style?: StyleProp - containerStyle?: StyleProp + onPressIn?: () => void + style?: StyleProp + children?: React.ReactNode }) { - const theme = useTheme() - const errPal = usePalette('error') - const [error, setError] = useState('') - const [imgInfo, setImgInfo] = useState() - const [containerInfo, setContainerInfo] = useState() - - useEffect(() => { - let aborted = false - if (!imgInfo) { - Image.getSize( - uri, - (width: number, height: number) => { - if (!aborted) { - setImgInfo({width, height}) - } - }, - (err: any) => { - if (!aborted) { - setError(String(err)) - } - }, - ) - } - return () => { - aborted = true - } - }, [uri, imgInfo]) - - const onLayout = (evt: LayoutChangeEvent) => { - setContainerInfo({ - width: evt.nativeEvent.layout.width, - height: evt.nativeEvent.layout.height, - }) - } - - let calculatedStyle: StyleProp | undefined - if (imgInfo && containerInfo) { - // imgInfo.height / imgInfo.width = x / containerInfo.width - // x = imgInfo.height / imgInfo.width * containerInfo.width - calculatedStyle = { - height: Math.min( - MAX_HEIGHT, - (imgInfo.height / imgInfo.width) * containerInfo.width, + const [aspectRatio, setAspectRatio] = React.useState(1) + const onLoad = (e: OnLoadEvent) => { + setAspectRatio( + clamp( + e.nativeEvent.width / e.nativeEvent.height, + MIN_ASPECT_RATIO, + MAX_ASPECT_RATIO, ), - } + ) } - return ( - - - {error ? ( - - {error} - - ) : calculatedStyle ? ( - - - - ) : ( - - )} - - + + + {children} + ) } const styles = StyleSheet.create({ - placeholder: { - width: '100%', - aspectRatio: 1, - }, - errorContainer: { - paddingHorizontal: 12, - paddingVertical: 8, - }, container: { overflow: 'hidden', }, + image: { + width: '100%', + }, }) diff --git a/src/view/com/util/images/Image.tsx b/src/view/com/util/images/Image.tsx new file mode 100644 index 000000000..8c95a581e --- /dev/null +++ b/src/view/com/util/images/Image.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import FastImage, {FastImageProps, Source} from 'react-native-fast-image' +export default FastImage +export type {OnLoadEvent, ImageStyle, Source} from 'react-native-fast-image' + +export function HighPriorityImage({source, ...props}: FastImageProps) { + const updatedSource = { + uri: typeof source === 'object' && source ? source.uri : '', + priority: FastImage.priority.high, + } as Source + return +} diff --git a/src/view/com/util/images/Image.web.tsx b/src/view/com/util/images/Image.web.tsx new file mode 100644 index 000000000..ecd9d730a --- /dev/null +++ b/src/view/com/util/images/Image.web.tsx @@ -0,0 +1,11 @@ +import { + Image, + NativeSyntheticEvent, + ImageLoadEventData, + ImageSourcePropType, +} from 'react-native' +export default Image +export const HighPriorityImage = Image +export type OnLoadEvent = NativeSyntheticEvent +export type Source = ImageSourcePropType +export type {ImageStyle} from 'react-native' diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx index 366424308..bed13406c 100644 --- a/src/view/com/util/images/ImageHorzList.tsx +++ b/src/view/com/util/images/ImageHorzList.tsx @@ -1,12 +1,12 @@ import React from 'react' import { - Image, StyleProp, StyleSheet, TouchableWithoutFeedback, View, ViewStyle, } from 'react-native' +import Image from 'view/com/util/images/Image' export function ImageHorzList({ uris, diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 97ad9d700..a1c732649 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,7 +1,5 @@ import React from 'react' import { - Image, - ImageStyle, LayoutChangeEvent, StyleProp, StyleSheet, @@ -9,7 +7,9 @@ import { View, ViewStyle, } from 'react-native' -import {DELAY_PRESS_IN} from './constants' +import Image, {ImageStyle} from 'view/com/util/images/Image' + +export const DELAY_PRESS_IN = 500 interface Dim { width: number @@ -23,12 +23,14 @@ export function ImageLayoutGrid({ uris, onPress, onLongPress, + onPressIn, style, }: { type: ImageLayoutGridType uris: string[] onPress?: (index: number) => void onLongPress?: (index: number) => void + onPressIn?: (index: number) => void style?: StyleProp }) { const [containerInfo, setContainerInfo] = React.useState() @@ -47,6 +49,7 @@ export function ImageLayoutGrid({ type={type} uris={uris} onPress={onPress} + onPressIn={onPressIn} onLongPress={onLongPress} containerInfo={containerInfo} /> @@ -60,15 +63,17 @@ function ImageLayoutGridInner({ uris, onPress, onLongPress, + onPressIn, containerInfo, }: { type: ImageLayoutGridType uris: string[] onPress?: (index: number) => void onLongPress?: (index: number) => void + onPressIn?: (index: number) => void containerInfo: Dim }) { - const size1 = React.useMemo(() => { + const size1 = React.useMemo>(() => { if (type === 'three') { const size = (containerInfo.width - 10) / 3 return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} @@ -77,7 +82,7 @@ function ImageLayoutGridInner({ return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} } }, [type, containerInfo]) - const size2 = React.useMemo(() => { + const size2 = React.useMemo>(() => { if (type === 'three') { const size = ((containerInfo.width - 10) / 3) * 2 + 5 return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} @@ -93,6 +98,7 @@ function ImageLayoutGridInner({ onPress?.(0)} + onPressIn={() => onPressIn?.(0)} onLongPress={() => onLongPress?.(0)}> @@ -100,6 +106,7 @@ function ImageLayoutGridInner({ onPress?.(1)} + onPressIn={() => onPressIn?.(1)} onLongPress={() => onLongPress?.(1)}> @@ -112,6 +119,7 @@ function ImageLayoutGridInner({ onPress?.(0)} + onPressIn={() => onPressIn?.(0)} onLongPress={() => onLongPress?.(0)}> @@ -120,6 +128,7 @@ function ImageLayoutGridInner({ onPress?.(1)} + onPressIn={() => onPressIn?.(1)} onLongPress={() => onLongPress?.(1)}> @@ -127,6 +136,7 @@ function ImageLayoutGridInner({ onPress?.(2)} + onPressIn={() => onPressIn?.(2)} onLongPress={() => onLongPress?.(2)}> @@ -141,29 +151,33 @@ function ImageLayoutGridInner({ onPress?.(0)} + onPressIn={() => onPressIn?.(0)} onLongPress={() => onLongPress?.(0)}> onPress?.(1)} - onLongPress={() => onLongPress?.(1)}> - + onPress={() => onPress?.(2)} + onPressIn={() => onPressIn?.(2)} + onLongPress={() => onLongPress?.(2)}> + onPress?.(2)} - onLongPress={() => onLongPress?.(2)}> - + onPress={() => onPress?.(1)} + onPressIn={() => onPressIn?.(1)} + onLongPress={() => onLongPress?.(1)}> + onPress?.(3)} + onPressIn={() => onPressIn?.(3)} onLongPress={() => onLongPress?.(3)}> diff --git a/src/view/com/util/images/constants.ts b/src/view/com/util/images/constants.ts deleted file mode 100644 index cb2c26cea..000000000 --- a/src/view/com/util/images/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const DELAY_PRESS_IN = 500 diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx index ddc9e87fd..d723fef99 100644 --- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx +++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx @@ -4,7 +4,7 @@ import { openCropper as openCropperFn, ImageOrVideo, } from 'react-native-image-crop-picker' -import {RootStoreModel} from '../../../../../state' +import {RootStoreModel} from 'state/index' import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' export type {PickedMedia} from './types' diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx index a7037f3a4..d632590d6 100644 --- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx +++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx @@ -1,9 +1,9 @@ /// -import {CropImageModal} from '../../../../../state/models/shell-ui' +import {CropImageModal} from 'state/models/shell-ui' import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' export type {PickedMedia} from './types' -import {RootStoreModel} from '../../../../../state' +import {RootStoreModel} from 'state/index' interface PickedFile { uri: string @@ -31,17 +31,17 @@ export async function openPicker( export async function openCamera( _store: RootStoreModel, - opts: CameraOpts, + _opts: CameraOpts, ): Promise { - const mediaType = opts.mediaType || 'photo' + // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } export async function openCropper( _store: RootStoreModel, - opts: CropperOpts, + _opts: CropperOpts, ): Promise { - const mediaType = opts.mediaType || 'photo' + // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index f04f4566d..d4cf19172 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -2,29 +2,21 @@ import React from 'react' import {TextStyle, StyleProp} from 'react-native' import {TextLink} from '../Link' import {Text} from './Text' -import {lh} from '../../../lib/styles' -import {toShortUrl} from '../../../../lib/strings' -import {useTheme, TypographyVariant} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' - -type TextSlice = {start: number; end: number} -type Entity = { - index: TextSlice - type: string - value: string -} +import {lh} from 'lib/styles' +import {toShortUrl} from 'lib/strings/url-helpers' +import {RichText as RichTextObj, Entity} from 'lib/strings/rich-text' +import {useTheme, TypographyVariant} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function RichText({ type = 'md', - text, - entities, + richText, lineHeight = 1.2, style, numberOfLines, }: { type?: TypographyVariant - text: string - entities?: Entity[] + richText?: RichTextObj lineHeight?: number style?: StyleProp numberOfLines?: number @@ -32,6 +24,12 @@ export function RichText({ const theme = useTheme() const pal = usePalette('default') const lineHeightStyle = lh(theme, type, lineHeight) + + if (!richText) { + return null + } + + const {text, entities} = richText if (!entities?.length) { if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) { style = { diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index c3a8a2194..14c57cf47 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -1,7 +1,7 @@ import React from 'react' import {Text as RNText, TextProps} from 'react-native' -import {s} from '../../../lib/styles' -import {useTheme, TypographyVariant} from '../../../lib/ThemeContext' +import {s} from 'lib/styles' +import {useTheme, TypographyVariant} from 'lib/ThemeContext' export type CustomTextProps = TextProps & { type?: TypographyVariant diff --git a/src/view/index.ts b/src/view/index.ts index 8ffd5957a..7cd2c1dfd 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -65,6 +65,8 @@ import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faX} from '@fortawesome/free-solid-svg-icons/faX' import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' +import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' +import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' export function setup() { library.add( @@ -133,5 +135,7 @@ export function setup() { faTrashCan, faX, faXmark, + faPlay, + faPause, ) } diff --git a/src/view/lib/ThemeContext.tsx b/src/view/lib/ThemeContext.tsx deleted file mode 100644 index 16a7d9cb3..000000000 --- a/src/view/lib/ThemeContext.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, {createContext, useContext, useMemo} from 'react' -import {TextStyle, useColorScheme, ViewStyle} from 'react-native' -import {darkTheme, defaultTheme} from './themes' - -export type ColorScheme = 'light' | 'dark' - -export type PaletteColorName = - | 'default' - | 'primary' - | 'secondary' - | 'inverted' - | 'error' -export type PaletteColor = { - background: string - backgroundLight: string - text: string - textLight: string - textInverted: string - link: string - border: string - borderDark: string - icon: string - [k: string]: string -} -export type Palette = Record - -export type ShapeName = 'button' | 'bigButton' | 'smallButton' -export type Shapes = Record - -export type TypographyVariant = - | 'xl-thin' - | 'xl' - | 'xl-medium' - | 'xl-bold' - | 'xl-heavy' - | 'lg-thin' - | 'lg' - | 'lg-medium' - | 'lg-bold' - | 'lg-heavy' - | 'md-thin' - | 'md' - | 'md-medium' - | 'md-bold' - | 'md-heavy' - | 'sm-thin' - | 'sm' - | 'sm-medium' - | 'sm-bold' - | 'sm-heavy' - | 'xs-thin' - | 'xs' - | 'xs-medium' - | 'xs-bold' - | 'xs-heavy' - | 'title-xl' - | 'title-lg' - | 'title' - | 'title-sm' - | 'post-text-lg' - | 'post-text' - | 'button' - | 'mono' -export type Typography = Record - -export interface Theme { - colorScheme: ColorScheme - palette: Palette - shapes: Shapes - typography: Typography -} - -export interface ThemeProviderProps { - theme?: ColorScheme -} - -export const ThemeContext = createContext(defaultTheme) - -export const useTheme = () => useContext(ThemeContext) - -export const ThemeProvider: React.FC = ({ - theme, - children, -}) => { - const colorScheme = useColorScheme() - - const value = useMemo( - () => ((theme || colorScheme) === 'dark' ? darkTheme : defaultTheme), - [colorScheme, theme], - ) - - return {children} -} diff --git a/src/view/lib/addStyle.ts b/src/view/lib/addStyle.ts deleted file mode 100644 index 93a5390a1..000000000 --- a/src/view/lib/addStyle.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {StyleProp} from 'react-native' - -export function addStyle( - base: StyleProp, - addedStyle: StyleProp, -): StyleProp { - if (Array.isArray(base)) { - return base.concat([addedStyle]) - } - return [base, addedStyle] -} diff --git a/src/view/lib/assets.native.ts b/src/view/lib/assets.native.ts deleted file mode 100644 index a2db553e7..000000000 --- a/src/view/lib/assets.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {ImageSourcePropType} from 'react-native' - -export const DEF_AVATAR: ImageSourcePropType = require('../../../public/img/default-avatar.jpg') -export const TABS_EXPLAINER: ImageSourcePropType = require('../../../public/img/tabs-explainer.jpg') -export const CLOUD_SPLASH: ImageSourcePropType = require('../../../public/img/cloud-splash.png') diff --git a/src/view/lib/assets.ts b/src/view/lib/assets.ts deleted file mode 100644 index 948f3a22f..000000000 --- a/src/view/lib/assets.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {ImageSourcePropType} from 'react-native' - -export const DEF_AVATAR: ImageSourcePropType = {uri: '/img/default-avatar.jpg'} -export const TABS_EXPLAINER: ImageSourcePropType = { - uri: '/img/tabs-explainer.jpg', -} -export const CLOUD_SPLASH: ImageSourcePropType = {uri: '/img/cloud-splash.png'} diff --git a/src/view/lib/hooks/useAnimatedValue.ts b/src/view/lib/hooks/useAnimatedValue.ts deleted file mode 100644 index 1307ef952..000000000 --- a/src/view/lib/hooks/useAnimatedValue.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from 'react' -import {Animated} from 'react-native' - -export function useAnimatedValue(initialValue: number) { - const lazyRef = React.useRef() - - if (lazyRef.current === undefined) { - lazyRef.current = new Animated.Value(initialValue) - } - - return lazyRef.current as Animated.Value -} diff --git a/src/view/lib/hooks/useOnMainScroll.ts b/src/view/lib/hooks/useOnMainScroll.ts deleted file mode 100644 index c3c16ff83..000000000 --- a/src/view/lib/hooks/useOnMainScroll.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {useState} from 'react' -import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' -import {RootStoreModel} from '../../../state' - -export type OnScrollCb = ( - event: NativeSyntheticEvent, -) => void - -export function useOnMainScroll(store: RootStoreModel) { - let [lastY, setLastY] = useState(0) - let isMinimal = store.shell.minimalShellMode - return function onMainScroll(event: NativeSyntheticEvent) { - const y = event.nativeEvent.contentOffset.y - const dy = y - (lastY || 0) - setLastY(y) - - if (!isMinimal && y > 10 && dy > 10) { - store.shell.setMinimalShellMode(true) - isMinimal = true - } else if (isMinimal && (y <= 10 || dy < -10)) { - store.shell.setMinimalShellMode(false) - isMinimal = false - } - } -} diff --git a/src/view/lib/hooks/usePalette.ts b/src/view/lib/hooks/usePalette.ts deleted file mode 100644 index 5b9929c7d..000000000 --- a/src/view/lib/hooks/usePalette.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {TextStyle, ViewStyle} from 'react-native' -import {useTheme, PaletteColorName, PaletteColor} from '../ThemeContext' - -export interface UsePaletteValue { - colors: PaletteColor - view: ViewStyle - btn: ViewStyle - border: ViewStyle - borderDark: ViewStyle - text: TextStyle - textLight: TextStyle - textInverted: TextStyle - link: TextStyle - icon: TextStyle -} -export function usePalette(color: PaletteColorName): UsePaletteValue { - const palette = useTheme().palette[color] - return { - colors: palette, - view: { - backgroundColor: palette.background, - }, - btn: { - backgroundColor: palette.backgroundLight, - }, - border: { - borderColor: palette.border, - }, - borderDark: { - borderColor: palette.borderDark, - }, - text: { - color: palette.text, - }, - textLight: { - color: palette.textLight, - }, - textInverted: { - color: palette.textInverted, - }, - link: { - color: palette.link, - }, - icon: { - color: palette.icon, - }, - } -} diff --git a/src/view/lib/icons.tsx b/src/view/lib/icons.tsx deleted file mode 100644 index f400c3f72..000000000 --- a/src/view/lib/icons.tsx +++ /dev/null @@ -1,529 +0,0 @@ -import React from 'react' -import {StyleProp, TextStyle, ViewStyle} from 'react-native' -import Svg, {Path, Rect} from 'react-native-svg' - -export function GridIcon({ - style, - solid, -}: { - style?: StyleProp - solid?: boolean -}) { - const DIM = 4 - const ARC = 2 - return ( - - - - - - - ) -} -export function GridIconSolid({style}: {style?: StyleProp}) { - return -} - -export function HomeIcon({ - style, - size, - strokeWidth = 4, -}: { - style?: StyleProp - size?: string | number - strokeWidth?: number -}) { - return ( - - - - ) -} - -export function HomeIconSolid({ - style, - size, -}: { - style?: StyleProp - size?: string | number -}) { - return ( - - - - ) -} - -// Copyright (c) 2020 Refactoring UI Inc. -// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE -export function MagnifyingGlassIcon({ - style, - size, - strokeWidth = 2, -}: { - style?: StyleProp - size?: string | number - strokeWidth?: number -}) { - return ( - - - - ) -} - -// https://github.com/Remix-Design/RemixIcon/blob/master/License -export function BellIcon({ - style, - size, -}: { - style?: StyleProp - size?: string | number -}) { - return ( - - - - - ) -} - -// https://github.com/Remix-Design/RemixIcon/blob/master/License -export function BellIconSolid({ - style, - size, -}: { - style?: StyleProp - size?: string | number -}) { - return ( - - - - - ) -} - -export function CogIcon({ - style, - size, - strokeWidth = 1.5, -}: { - style?: StyleProp - size?: string | number - strokeWidth: number -}) { - return ( - - - - - ) -} - -// Copyright (c) 2020 Refactoring UI Inc. -// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE -export function UserIcon({ - style, - size, - strokeWidth = 1.5, -}: { - style?: StyleProp - size?: string | number - strokeWidth?: number -}) { - return ( - - - - ) -} - -// Copyright (c) 2020 Refactoring UI Inc. -// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE -export function UserGroupIcon({ - style, - size, -}: { - style?: StyleProp - size?: string | number -}) { - return ( - - - - ) -} - -export function RepostIcon({ - style, - size = 24, - strokeWidth = 1.5, -}: { - style?: StyleProp - size?: string | number - strokeWidth: number -}) { - return ( - - - - ) -} - -// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -export function HeartIcon({ - style, - size = 24, - strokeWidth = 1.5, -}: { - style?: StyleProp - size?: string | number - strokeWidth: number -}) { - return ( - - - - ) -} - -// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -export function HeartIconSolid({ - style, - size = 24, -}: { - style?: StyleProp - size?: string | number -}) { - return ( - - - - ) -} - -export function UpIcon({ - style, - size, - strokeWidth = 1.3, -}: { - style?: StyleProp - size?: string | number - strokeWidth: number -}) { - return ( - - - - ) -} - -export function UpIconSolid({ - style, - size, -}: { - style?: StyleProp - size?: string | number -}) { - return ( - - - - ) -} - -export function DownIcon({ - style, - size, -}: { - style?: StyleProp - size?: string | number -}) { - return ( - - - - ) -} - -export function DownIconSolid({ - style, - size, -}: { - style?: StyleProp - size?: string | number -}) { - return ( - - - - ) -} - -// Copyright (c) 2020 Refactoring UI Inc. -// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE -export function CommentBottomArrow({ - style, - size, - strokeWidth = 1.3, -}: { - style?: StyleProp - size?: string | number - strokeWidth?: number -}) { - let color = 'currentColor' - if ( - style && - typeof style === 'object' && - 'color' in style && - typeof style.color === 'string' - ) { - color = style.color - } - return ( - - - - ) -} - -export function SquareIcon({ - style, - size, - strokeWidth = 1.3, -}: { - style?: StyleProp - size?: string | number - strokeWidth?: number -}) { - return ( - - - - ) -} - -export function RectWideIcon({ - style, - size, - strokeWidth = 1.3, -}: { - style?: StyleProp - size?: string | number - strokeWidth?: number -}) { - return ( - - - - ) -} - -export function RectTallIcon({ - style, - size, - strokeWidth = 1.3, -}: { - style?: StyleProp - size?: string | number - strokeWidth?: number -}) { - return ( - - - - ) -} diff --git a/src/view/lib/notifee.ts b/src/view/lib/notifee.ts deleted file mode 100644 index 5e1917381..000000000 --- a/src/view/lib/notifee.ts +++ /dev/null @@ -1,54 +0,0 @@ -import notifee from '@notifee/react-native' -import {AppBskyEmbedImages} from '@atproto/api' -import {NotificationsViewItemModel} from '../../state/models/notifications-view' -import {enforceLen} from '../../lib/strings' - -export function displayNotification( - title: string, - body?: string, - image?: string, -) { - const opts: {title: string; body?: string; ios?: any} = {title} - if (body) { - opts.body = enforceLen(body, 70, true) - } - if (image) { - opts.ios = { - attachments: [{url: image}], - } - } - return notifee.displayNotification(opts) -} - -export function displayNotificationFromModel( - notif: NotificationsViewItemModel, -) { - let author = notif.author.displayName || notif.author.handle - let title: string - let body: string = '' - if (notif.isUpvote) { - title = `${author} liked your post` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isRepost) { - title = `${author} reposted your post` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isMention) { - title = `${author} mentioned you` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isReply) { - title = `${author} replied to your post` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isFollow) { - title = `${author} followed you` - } else { - return - } - let image - if ( - AppBskyEmbedImages.isPresented(notif.additionalPost?.thread?.post.embed) && - notif.additionalPost?.thread?.post.embed.images[0]?.thumb - ) { - image = notif.additionalPost.thread.post.embed.images[0].thumb - } - return displayNotification(title, body, image) -} diff --git a/src/view/lib/styles.ts b/src/view/lib/styles.ts deleted file mode 100644 index 7129867e9..000000000 --- a/src/view/lib/styles.ts +++ /dev/null @@ -1,208 +0,0 @@ -import {StyleSheet, TextStyle} from 'react-native' -import {Theme, TypographyVariant} from './ThemeContext' - -// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest -export const colors = { - white: '#ffffff', - black: '#000000', - - gray1: '#F3F3F8', - gray2: '#E2E2E4', - gray3: '#B9B9C1', - gray4: '#8D8E96', - gray5: '#545664', - gray6: '#373942', - gray7: '#26272D', - gray8: '#101013', - - blue0: '#bfe1ff', - blue1: '#8bc7fd', - blue2: '#52acfe', - blue3: '#0085ff', - blue4: '#0062bd', - blue5: '#034581', - - red1: '#ffe6f2', - red2: '#fba2ce', - red3: '#ec4899', - red4: '#d1106f', - red5: '#97074e', - - pink1: '#f8ccff', - pink2: '#e966ff', - pink3: '#db00ff', - pink4: '#a601c1', - pink5: '#570066', - - purple1: '#ebdbff', - purple2: '#ba85ff', - purple3: '#9747ff', - purple4: '#6d00fa', - purple5: '#380080', - - green1: '#c1ffb8', - green2: '#27f406', - green3: '#20bc07', - green4: '#148203', - green5: '#082b03', - - unreadNotifBg: '#ebf6ff', -} - -export const gradients = { - blueLight: {start: '#5A71FA', end: colors.blue3}, // buttons - blue: {start: '#5E55FB', end: colors.blue3}, // fab - blueDark: {start: '#5F45E0', end: colors.blue3}, // avis, banner -} - -export const s = StyleSheet.create({ - // helpers - footerSpacer: {height: 100}, - contentContainer: {paddingBottom: 200}, - border1: {borderWidth: 1}, - - // font weights - fw600: {fontWeight: '600'}, - bold: {fontWeight: 'bold'}, - fw500: {fontWeight: '500'}, - semiBold: {fontWeight: '500'}, - fw400: {fontWeight: '400'}, - normal: {fontWeight: '400'}, - fw300: {fontWeight: '300'}, - light: {fontWeight: '300'}, - fw200: {fontWeight: '200'}, - - // text decoration - underline: {textDecorationLine: 'underline'}, - - // font sizes - f9: {fontSize: 9}, - f10: {fontSize: 10}, - f11: {fontSize: 11}, - f12: {fontSize: 12}, - f13: {fontSize: 13}, - f14: {fontSize: 14}, - f15: {fontSize: 15}, - f16: {fontSize: 16}, - f17: {fontSize: 17}, - f18: {fontSize: 18}, - - // line heights - ['lh13-1']: {lineHeight: 13}, - ['lh13-1.3']: {lineHeight: 16.9}, // 1.3 of 13px - ['lh14-1']: {lineHeight: 14}, - ['lh14-1.3']: {lineHeight: 18.2}, // 1.3 of 14px - ['lh15-1']: {lineHeight: 15}, - ['lh15-1.3']: {lineHeight: 19.5}, // 1.3 of 15px - ['lh16-1']: {lineHeight: 16}, - ['lh16-1.3']: {lineHeight: 20.8}, // 1.3 of 16px - ['lh17-1']: {lineHeight: 17}, - ['lh17-1.3']: {lineHeight: 22.1}, // 1.3 of 17px - ['lh18-1']: {lineHeight: 18}, - ['lh18-1.3']: {lineHeight: 23.4}, // 1.3 of 18px - - // margins - mr2: {marginRight: 2}, - mr5: {marginRight: 5}, - mr10: {marginRight: 10}, - ml2: {marginLeft: 2}, - ml5: {marginLeft: 5}, - ml10: {marginLeft: 10}, - mt2: {marginTop: 2}, - mt5: {marginTop: 5}, - mt10: {marginTop: 10}, - mb2: {marginBottom: 2}, - mb5: {marginBottom: 5}, - mb10: {marginBottom: 10}, - - // paddings - p2: {padding: 2}, - p5: {padding: 5}, - p10: {padding: 10}, - p20: {padding: 20}, - pr2: {paddingRight: 2}, - pr5: {paddingRight: 5}, - pr10: {paddingRight: 10}, - pr20: {paddingRight: 20}, - pl2: {paddingLeft: 2}, - pl5: {paddingLeft: 5}, - pl10: {paddingLeft: 10}, - pl20: {paddingLeft: 20}, - pt2: {paddingTop: 2}, - pt5: {paddingTop: 5}, - pt10: {paddingTop: 10}, - pt20: {paddingTop: 20}, - pb2: {paddingBottom: 2}, - pb5: {paddingBottom: 5}, - pb10: {paddingBottom: 10}, - pb20: {paddingBottom: 20}, - - // flex - flexRow: {flexDirection: 'row'}, - flexCol: {flexDirection: 'column'}, - flex1: {flex: 1}, - alignCenter: {alignItems: 'center'}, - alignBaseline: {alignItems: 'baseline'}, - - // position - absolute: {position: 'absolute'}, - - // dimensions - w100pct: {width: '100%'}, - h100pct: {height: '100%'}, - - // text align - textLeft: {textAlign: 'left'}, - textCenter: {textAlign: 'center'}, - textRight: {textAlign: 'right'}, - - // colors - white: {color: colors.white}, - black: {color: colors.black}, - - gray1: {color: colors.gray1}, - gray2: {color: colors.gray2}, - gray3: {color: colors.gray3}, - gray4: {color: colors.gray4}, - gray5: {color: colors.gray5}, - - blue1: {color: colors.blue1}, - blue2: {color: colors.blue2}, - blue3: {color: colors.blue3}, - blue4: {color: colors.blue4}, - blue5: {color: colors.blue5}, - - red1: {color: colors.red1}, - red2: {color: colors.red2}, - red3: {color: colors.red3}, - red4: {color: colors.red4}, - red5: {color: colors.red5}, - - pink1: {color: colors.pink1}, - pink2: {color: colors.pink2}, - pink3: {color: colors.pink3}, - pink4: {color: colors.pink4}, - pink5: {color: colors.pink5}, - - purple1: {color: colors.purple1}, - purple2: {color: colors.purple2}, - purple3: {color: colors.purple3}, - purple4: {color: colors.purple4}, - purple5: {color: colors.purple5}, - - green1: {color: colors.green1}, - green2: {color: colors.green2}, - green3: {color: colors.green3}, - green4: {color: colors.green4}, - green5: {color: colors.green5}, -}) - -export function lh( - theme: Theme, - type: TypographyVariant, - height: number, -): TextStyle { - return { - lineHeight: (theme.typography[type].fontSize || 16) * height, - } -} diff --git a/src/view/lib/themes.ts b/src/view/lib/themes.ts deleted file mode 100644 index 84e2b7883..000000000 --- a/src/view/lib/themes.ts +++ /dev/null @@ -1,297 +0,0 @@ -import {Platform} from 'react-native' -import type {Theme} from './ThemeContext' -import {colors} from './styles' - -export const defaultTheme: Theme = { - colorScheme: 'light', - palette: { - default: { - background: colors.white, - backgroundLight: colors.gray1, - text: colors.black, - textLight: colors.gray5, - textInverted: colors.white, - link: colors.blue3, - border: '#f0e9e9', - borderDark: '#e0d9d9', - icon: colors.gray3, - - // non-standard - textVeryLight: colors.gray4, - replyLine: colors.gray2, - replyLineDot: colors.gray3, - unreadNotifBg: '#ebf6ff', - postCtrl: '#71768A', - brandText: '#0066FF', - emptyStateIcon: '#B6B6C9', - }, - primary: { - background: colors.blue3, - backgroundLight: colors.blue2, - text: colors.white, - textLight: colors.blue0, - textInverted: colors.blue3, - link: colors.blue0, - border: colors.blue4, - borderDark: colors.blue5, - icon: colors.blue4, - }, - secondary: { - background: colors.green3, - backgroundLight: colors.green2, - text: colors.white, - textLight: colors.green1, - textInverted: colors.green4, - link: colors.green1, - border: colors.green4, - borderDark: colors.green5, - icon: colors.green4, - }, - inverted: { - background: colors.black, - backgroundLight: colors.gray6, - text: colors.white, - textLight: colors.gray3, - textInverted: colors.black, - link: colors.blue2, - border: colors.gray3, - borderDark: colors.gray2, - icon: colors.gray5, - }, - error: { - background: colors.red3, - backgroundLight: colors.red2, - text: colors.white, - textLight: colors.red1, - textInverted: colors.red3, - link: colors.red1, - border: colors.red4, - borderDark: colors.red5, - icon: colors.red4, - }, - }, - shapes: { - button: { - // TODO - }, - bigButton: { - // TODO - }, - smallButton: { - // TODO - }, - }, - typography: { - 'xl-thin': { - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '300', - }, - xl: { - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '400', - }, - 'xl-medium': { - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'xl-bold': { - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '700', - }, - 'xl-heavy': { - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '800', - }, - 'lg-thin': { - fontSize: 16, - letterSpacing: 0.25, - fontWeight: '300', - }, - lg: { - fontSize: 16, - letterSpacing: 0.25, - fontWeight: '400', - }, - 'lg-medium': { - fontSize: 16, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'lg-bold': { - fontSize: 16, - letterSpacing: 0.25, - fontWeight: '700', - }, - 'lg-heavy': { - fontSize: 16, - letterSpacing: 0.25, - fontWeight: '800', - }, - 'md-thin': { - fontSize: 15, - letterSpacing: 0.25, - fontWeight: '300', - }, - md: { - fontSize: 15, - letterSpacing: 0.25, - fontWeight: '400', - }, - 'md-medium': { - fontSize: 15, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'md-bold': { - fontSize: 15, - letterSpacing: 0.25, - fontWeight: '700', - }, - 'md-heavy': { - fontSize: 15, - letterSpacing: 0.25, - fontWeight: '800', - }, - 'sm-thin': { - fontSize: 14, - letterSpacing: 0.25, - fontWeight: '300', - }, - sm: { - fontSize: 14, - letterSpacing: 0.25, - fontWeight: '400', - }, - 'sm-medium': { - fontSize: 14, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'sm-bold': { - fontSize: 14, - letterSpacing: 0.25, - fontWeight: '700', - }, - 'sm-heavy': { - fontSize: 14, - letterSpacing: 0.25, - fontWeight: '800', - }, - 'xs-thin': { - fontSize: 13, - letterSpacing: 0.25, - fontWeight: '300', - }, - xs: { - fontSize: 13, - letterSpacing: 0.25, - fontWeight: '400', - }, - 'xs-medium': { - fontSize: 13, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'xs-bold': { - fontSize: 13, - letterSpacing: 0.25, - fontWeight: '700', - }, - 'xs-heavy': { - fontSize: 13, - letterSpacing: 0.25, - fontWeight: '800', - }, - - 'title-xl': { - fontSize: 34, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'title-lg': { - fontSize: 22, - fontWeight: '500', - }, - title: { - fontWeight: '500', - fontSize: 20, - letterSpacing: 0.15, - }, - 'title-sm': { - fontWeight: 'bold', - fontSize: 17, - letterSpacing: 0.15, - }, - 'post-text': { - fontSize: 16, - letterSpacing: 0.2, - fontWeight: '400', - }, - 'post-text-lg': { - fontSize: 22, - letterSpacing: 0.4, - fontWeight: '400', - }, - button: { - fontWeight: '500', - fontSize: 14, - letterSpacing: 0.5, - }, - mono: { - fontSize: 14, - fontFamily: Platform.OS === 'android' ? 'monospace' : 'Courier New', - }, - }, -} - -export const darkTheme: Theme = { - ...defaultTheme, - colorScheme: 'dark', - palette: { - ...defaultTheme.palette, - default: { - background: colors.gray8, - backgroundLight: colors.gray6, - text: colors.white, - textLight: colors.gray3, - textInverted: colors.black, - link: colors.blue3, - border: colors.gray6, - borderDark: colors.gray5, - icon: colors.gray5, - - // non-standard - textVeryLight: colors.gray4, - replyLine: colors.gray5, - replyLineDot: colors.gray6, - unreadNotifBg: colors.blue5, - postCtrl: '#61657A', - brandText: '#0085ff', - emptyStateIcon: colors.gray4, - }, - primary: { - ...defaultTheme.palette.primary, - textInverted: colors.blue2, - }, - secondary: { - ...defaultTheme.palette.secondary, - textInverted: colors.green2, - }, - inverted: { - background: colors.white, - backgroundLight: colors.gray2, - text: colors.black, - textLight: colors.gray5, - textInverted: colors.white, - link: colors.blue3, - border: colors.gray3, - borderDark: colors.gray4, - icon: colors.gray1, - }, - }, -} diff --git a/src/view/routes.ts b/src/view/routes.ts index 98e8111b3..1cd9ef8e2 100644 --- a/src/view/routes.ts +++ b/src/view/routes.ts @@ -1,5 +1,4 @@ -import React, {MutableRefObject} from 'react' -import {FlatList} from 'react-native' +import React from 'react' import {IconProp} from '@fortawesome/fontawesome-svg-core' import {Home} from './screens/Home' import {Contacts} from './screens/Contacts' @@ -21,7 +20,6 @@ export type ScreenParams = { navIdx: string params: Record visible: boolean - scrollElRef?: MutableRefObject | null> } export type Route = [React.FC, string, IconProp, RegExp] export type MatchResult = { diff --git a/src/view/screens/Contacts.tsx b/src/view/screens/Contacts.tsx index cba17f285..21943a10a 100644 --- a/src/view/screens/Contacts.tsx +++ b/src/view/screens/Contacts.tsx @@ -4,10 +4,10 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows' import {Selector} from '../com/util/Selector' import {Text} from '../com/util/text/Text' -import {colors} from '../lib/styles' +import {colors} from 'lib/styles' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {useAnimatedValue} from '../lib/hooks/useAnimatedValue' +import {useStores} from 'state/index' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' export const Contacts = ({navIdx, visible}: ScreenParams) => { const store = useStores() diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx index 0223e631d..09e3dd46a 100644 --- a/src/view/screens/Debug.tsx +++ b/src/view/screens/Debug.tsx @@ -1,11 +1,10 @@ import React from 'react' import {ScrollView, View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' -import {ThemeProvider} from '../lib/ThemeContext' -import {PaletteColorName} from '../lib/ThemeContext' -import {usePalette} from '../lib/hooks/usePalette' -import {s} from '../lib/styles' -import {displayNotification} from '../lib/notifee' +import {ThemeProvider, PaletteColorName} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {displayNotification} from 'lib/notifee' import {Text} from '../com/util/text/Text' import {ViewSelector} from '../com/util/ViewSelector' @@ -284,6 +283,9 @@ function TypographyView() { 'xs-heavy' lorem ipsum dolor + + 'title-2xl' lorem ipsum dolor + 'title-xl' lorem ipsum dolor @@ -296,6 +298,9 @@ function TypographyView() { Button + + Button-lg + ) } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 4222c7513..de7e61ba4 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,23 +1,24 @@ import React, {useEffect} from 'react' -import {View} from 'react-native' +import {FlatList, View} from 'react-native' import {observer} from 'mobx-react-lite' import useAppState from 'react-native-appstate-hook' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/posts/Feed' import {FAB} from '../com/util/FAB' import {LoadLatestBtn} from '../com/util/LoadLatestBtn' -import {useStores} from '../../state' +import {useStores} from 'state/index' import {ScreenParams} from '../routes' -import {s} from '../lib/styles' -import {useOnMainScroll} from '../lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {useAnalytics} from 'lib/analytics' -export const Home = observer(function Home({ - navIdx, - visible, - scrollElRef, -}: ScreenParams) { +const HEADER_HEIGHT = 42 + +export const Home = observer(function Home({navIdx, visible}: ScreenParams) { const store = useStores() const onMainScroll = useOnMainScroll(store) + const {screen, track} = useAnalytics() + const scrollElRef = React.useRef(null) const [wasVisible, setWasVisible] = React.useState(false) const {appState} = useAppState({ onForeground: () => doPoll(true), @@ -31,22 +32,31 @@ export const Home = observer(function Home({ if (store.me.mainFeed.isLoading) { return } - store.log.debug('Polling home feed') - store.me.mainFeed.checkForLatest().catch(e => { - store.log.error('Failed to poll feed', e) - }) + store.log.debug('HomeScreen: Polling for new posts') + store.me.mainFeed.checkForLatest() }, [appState, visible, store], ) + const scrollToTop = React.useCallback(() => { + // NOTE: the feed is offset by the height of the collapsing header, + // so we scroll to the negative of that height -prf + scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT}) + }, [scrollElRef]) + useEffect(() => { + const softResetSub = store.onScreenSoftReset(scrollToTop) const feedCleanup = store.me.mainFeed.registerListeners() - const pollInterval = setInterval(() => doPoll(), 15e3) + const pollInterval = setInterval(doPoll, 15e3) const cleanup = () => { clearInterval(pollInterval) + softResetSub.remove() feedCleanup() } + // guard to only continue when transitioning from !visible -> visible + // TODO is this 100% needed? depends on if useEffect() is getting refired + // for reasons other than `visible` changing -prf if (!visible) { setWasVisible(false) return cleanup @@ -55,17 +65,20 @@ export const Home = observer(function Home({ } setWasVisible(true) + // just became visible + screen('Feed') store.nav.setTitle(navIdx, 'Home') - store.log.debug('Updating home feed') + store.log.debug('HomeScreen: Updating feed') if (store.me.mainFeed.hasContent) { store.me.mainFeed.update() } else { store.me.mainFeed.setup() } return cleanup - }, [visible, store, navIdx, doPoll, wasVisible]) + }, [visible, store, store.me.mainFeed, navIdx, doPoll, wasVisible, scrollToTop, screen]) const onPressCompose = (imagesOpen?: boolean) => { + track('Home:ComposeButtonPressed') store.shell.openComposer({imagesOpen}) } const onPressTryAgain = () => { @@ -73,26 +86,31 @@ export const Home = observer(function Home({ } const onPressLoadLatest = () => { store.me.mainFeed.refresh() - scrollElRef?.current?.scrollToOffset({offset: 0}) + scrollToTop() } return ( - + {store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && ( )} - onPressCompose(false)} /> + onPressCompose(false)} + /> ) }) diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index c3e156dcb..c067d3506 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -3,13 +3,13 @@ import {StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ScrollView} from '../com/util/Views' -import {useStores} from '../../state' +import {useStores} from 'state/index' import {ScreenParams} from '../routes' -import {s} from '../lib/styles' +import {s} from 'lib/styles' import {ViewHeader} from '../com/util/ViewHeader' import {Text} from '../com/util/text/Text' -import {usePalette} from '../lib/hooks/usePalette' -import {ago} from '../../lib/strings' +import {usePalette} from 'lib/hooks/usePalette' +import {ago} from 'lib/strings/time' export const Log = observer(function Log({navIdx, visible}: ScreenParams) { const pal = usePalette('default') diff --git a/src/view/screens/Login.tsx b/src/view/screens/Login.tsx index 81a2c9e6b..50b2a34c0 100644 --- a/src/view/screens/Login.tsx +++ b/src/view/screens/Login.tsx @@ -1,19 +1,16 @@ -import React, {useState} from 'react' -import { - Image, - SafeAreaView, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' +import React, {useEffect, useState} from 'react' +import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' +import Image, {Source as ImageSource} from 'view/com/util/images/Image' import {observer} from 'mobx-react-lite' import {Signin} from '../com/login/Signin' import {CreateAccount} from '../com/login/CreateAccount' import {Text} from '../com/util/text/Text' import {ErrorBoundary} from '../com/util/ErrorBoundary' -import {colors} from '../lib/styles' -import {usePalette} from '../lib/hooks/usePalette' -import {CLOUD_SPLASH} from '../lib/assets' +import {colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {CLOUD_SPLASH} from 'lib/assets' +import {useAnalytics} from 'lib/analytics' enum ScreenState { S_SigninOrCreateAccount, @@ -28,6 +25,12 @@ const SigninOrCreateAccount = ({ onPressSignin: () => void onPressCreateAccount: () => void }) => { + const {screen} = useAnalytics() + + useEffect(() => { + screen('Login') + }, [screen]) + const pal = usePalette('default') return ( <> @@ -57,22 +60,28 @@ const SigninOrCreateAccount = ({ export const Login = observer(() => { const pal = usePalette('default') + const store = useStores() const [screenState, setScreenState] = useState( ScreenState.S_SigninOrCreateAccount, ) - if (screenState === ScreenState.S_SigninOrCreateAccount) { + if ( + store.session.isResumingSession || + screenState === ScreenState.S_SigninOrCreateAccount + ) { return ( - + - setScreenState(ScreenState.S_Signin)} - onPressCreateAccount={() => - setScreenState(ScreenState.S_CreateAccount) - } - /> + {!store.session.isResumingSession && ( + setScreenState(ScreenState.S_Signin)} + onPressCreateAccount={() => + setScreenState(ScreenState.S_CreateAccount) + } + /> + )} diff --git a/src/view/screens/Login.web.tsx b/src/view/screens/Login.web.tsx index 77149090c..90effc5d6 100644 --- a/src/view/screens/Login.web.tsx +++ b/src/view/screens/Login.web.tsx @@ -1,20 +1,13 @@ import React, {useState} from 'react' -import { - Image, - SafeAreaView, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' +import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {CenteredView} from '../com/util/Views' import {Signin} from '../com/login/Signin' import {CreateAccount} from '../com/login/CreateAccount' import {Text} from '../com/util/text/Text' import {ErrorBoundary} from '../com/util/ErrorBoundary' -import {colors} from '../lib/styles' -import {usePalette} from '../lib/hooks/usePalette' -import {CLOUD_SPLASH} from '../lib/assets' +import {colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' enum ScreenState { S_SigninOrCreateAccount, @@ -125,6 +118,7 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', }, + hero: {}, heroText: { backgroundColor: colors.white, paddingTop: 10, diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index c5c5ff002..77bbdd2aa 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -2,7 +2,7 @@ import React from 'react' import {Button, StyleSheet, View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {Text} from '../com/util/text/Text' -import {useStores} from '../../state' +import {useStores} from 'state/index' export const NotFound = () => { const stores = useStores() diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 9b5dc5970..548b0d564 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -1,35 +1,79 @@ import React, {useEffect} from 'react' -import {View} from 'react-native' +import {FlatList, View} from 'react-native' +import useAppState from 'react-native-appstate-hook' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/notifications/Feed' -import {useStores} from '../../state' +import {useStores} from 'state/index' import {ScreenParams} from '../routes' -import {useOnMainScroll} from '../lib/hooks/useOnMainScroll' -import {s} from '../lib/styles' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' +import {useAnalytics} from 'lib/analytics' + +const NOTIFICATIONS_POLL_INTERVAL = 15e3 export const Notifications = ({navIdx, visible}: ScreenParams) => { const store = useStores() const onMainScroll = useOnMainScroll(store) + const scrollElRef = React.useRef(null) + const {screen} = useAnalytics() + const {appState} = useAppState({ + onForeground: () => doPoll(true), + }) + // event handlers + // = + const onPressTryAgain = () => { + store.me.notifications.refresh() + } + const scrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: 0}) + }, [scrollElRef]) + + // periodic polling + // = + const doPoll = React.useCallback( + async (isForegrounding = false) => { + if (isForegrounding) { + // app is foregrounding, refresh optimistically + store.log.debug('NotificationsScreen: Refreshing on app foreground') + await Promise.all([ + store.me.notifications.loadUnreadCount(), + store.me.notifications.refresh(), + ]) + } else if (appState === 'active') { + // periodic poll, refresh if there are new notifs + store.log.debug('NotificationsScreen: Polling for new notifications') + const didChange = await store.me.notifications.loadUnreadCount() + if (didChange) { + store.log.debug('NotificationsScreen: Loading new notifications') + await store.me.notifications.loadLatest() + } + } + }, + [appState, store], + ) + useEffect(() => { + const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL) + return () => clearInterval(pollInterval) + }, [doPoll]) + + // on-visible setup + // = useEffect(() => { if (!visible) { return } - store.log.debug('Updating notifications feed') - store.me.notifications - .update() - .catch(e => { - store.log.error('Error while updating notifications feed', e) - }) - .then(() => { - store.me.notifications.updateReadState() - }) + store.log.debug('NotificationsScreen: Updating feed') + const softResetSub = store.onScreenSoftReset(scrollToTop) + store.me.notifications.update().then(() => { + store.me.notifications.markAllRead() + }) + screen('Notifications') store.nav.setTitle(navIdx, 'Notifications') - }, [visible, store, navIdx]) - - const onPressTryAgain = () => { - store.me.notifications.refresh() - } + return () => { + softResetSub.remove() + } + }, [visible, store, navIdx, screen, scrollToTop]) return ( @@ -38,6 +82,7 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => { view={store.me.notifications} onPressTryAgain={onPressTryAgain} onScroll={onMainScroll} + scrollElRef={scrollElRef} /> ) diff --git a/src/view/screens/Onboard.tsx b/src/view/screens/Onboard.tsx index e31b42adc..1485670e7 100644 --- a/src/view/screens/Onboard.tsx +++ b/src/view/screens/Onboard.tsx @@ -3,8 +3,8 @@ import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' import {FeatureExplainer} from '../com/onboard/FeatureExplainer' import {Follows} from '../com/onboard/Follows' -import {OnboardStage, OnboardStageOrder} from '../../state/models/onboard' -import {useStores} from '../../state' +import {OnboardStage, OnboardStageOrder} from 'state/models/onboard' +import {useStores} from 'state/index' export const Onboard = observer(() => { const store = useStores() diff --git a/src/view/screens/PostDownvotedBy.tsx b/src/view/screens/PostDownvotedBy.tsx index 1401868d4..570482598 100644 --- a/src/view/screens/PostDownvotedBy.tsx +++ b/src/view/screens/PostDownvotedBy.tsx @@ -3,8 +3,8 @@ import {View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {makeRecordUri} from '../../lib/strings' +import {useStores} from 'state/index' +import {makeRecordUri} from 'lib/strings/url-helpers' export const PostDownvotedBy = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx index bf4d6ec91..4be4b4b42 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/view/screens/PostRepostedBy.tsx @@ -3,8 +3,8 @@ import {View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {makeRecordUri} from '../../lib/strings' +import {useStores} from 'state/index' +import {makeRecordUri} from 'lib/strings/url-helpers' export const PostRepostedBy = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index febaddc09..4b799468d 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,17 +1,16 @@ -import React, {useEffect, useMemo, useState} from 'react' +import React, {useEffect, useMemo} from 'react' import {View} from 'react-native' -import {makeRecordUri} from '../../lib/strings' +import {makeRecordUri} from 'lib/strings/url-helpers' import {ViewHeader} from '../com/util/ViewHeader' import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' -import {PostThreadViewModel} from '../../state/models/post-thread-view' +import {PostThreadViewModel} from 'state/models/post-thread-view' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {s} from '../lib/styles' +import {useStores} from 'state/index' +import {s} from 'lib/styles' export const PostThread = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() const {name, rkey} = params - const [viewSubtitle, setViewSubtitle] = useState(`by ${name}`) const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const view = useMemo( () => new PostThreadViewModel(store, {uri}), @@ -24,7 +23,6 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => { const setTitle = () => { const author = view.thread?.post.author const niceName = author?.handle || name - setViewSubtitle(`by ${niceName}`) store.nav.setTitle(navIdx, `Post by ${niceName}`) } if (!visible) { @@ -52,7 +50,7 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => { return ( - + diff --git a/src/view/screens/PostUpvotedBy.tsx b/src/view/screens/PostUpvotedBy.tsx index 4bba222ae..4d6ad4114 100644 --- a/src/view/screens/PostUpvotedBy.tsx +++ b/src/view/screens/PostUpvotedBy.tsx @@ -3,8 +3,8 @@ import {View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {makeRecordUri} from '../../lib/strings' +import {useStores} from 'state/index' +import {makeRecordUri} from 'lib/strings/url-helpers' export const PostUpvotedBy = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 5c6616985..03d973b96 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -4,8 +4,8 @@ import {observer} from 'mobx-react-lite' import {ViewSelector} from '../com/util/ViewSelector' import {CenteredView} from '../com/util/Views' import {ScreenParams} from '../routes' -import {ProfileUiModel, Sections} from '../../state/models/profile-ui' -import {useStores} from '../../state' +import {ProfileUiModel, Sections} from 'state/models/profile-ui' +import {useStores} from 'state/index' import {ProfileHeader} from '../com/profile/ProfileHeader' import {FeedItem} from '../com/posts/FeedItem' import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder' @@ -14,8 +14,9 @@ import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' import {Text} from '../com/util/text/Text' import {FAB} from '../com/util/FAB' -import {s, colors} from '../lib/styles' -import {useOnMainScroll} from '../lib/hooks/useOnMainScroll' +import {s, colors} from 'lib/styles' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {useAnalytics} from 'lib/analytics' const LOADING_ITEM = {_reactKey: '__loading__'} const END_ITEM = {_reactKey: '__end__'} @@ -23,6 +24,12 @@ const EMPTY_ITEM = {_reactKey: '__empty__'} export const Profile = observer(({navIdx, visible, params}: ScreenParams) => { const store = useStores() + const {screen} = useAnalytics() + + useEffect(() => { + screen('Profile') + }, [screen]) + const onMainScroll = useOnMainScroll(store) const [hasSetup, setHasSetup] = useState(false) const uiState = React.useMemo( @@ -128,7 +135,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => { } if (!uiState.feed.hasMore) { items = items.concat([END_ITEM]) - } else { + } else if (uiState.feed.isLoading) { Footer = LoadingMoreFooter } renderItem = (item: any) => { @@ -184,7 +191,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => { ) : ( {renderHeader()} )} - + ) }) diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx index f7520549e..9f1a9c741 100644 --- a/src/view/screens/ProfileFollowers.tsx +++ b/src/view/screens/ProfileFollowers.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers' import {ScreenParams} from '../routes' -import {useStores} from '../../state' +import {useStores} from 'state/index' export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() @@ -18,7 +18,7 @@ export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => { return ( - + ) diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx index 65e4004e9..1cdb5bccf 100644 --- a/src/view/screens/ProfileFollows.tsx +++ b/src/view/screens/ProfileFollows.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows' import {ScreenParams} from '../routes' -import {useStores} from '../../state' +import {useStores} from 'state/index' export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() @@ -18,7 +18,7 @@ export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => { return ( - + ) diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index 2a1caab89..2e176d98f 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -1,41 +1,73 @@ -import React, {useEffect, useState, useMemo, useRef} from 'react' +import React from 'react' import { Keyboard, StyleSheet, TextInput, TouchableOpacity, + TouchableWithoutFeedback, View, } from 'react-native' -import {ViewHeader} from '../com/util/ViewHeader' -import {CenteredView, ScrollView} from '../com/util/Views' -import {SuggestedFollows} from '../com/discover/SuggestedFollows' +import {ScrollView} from '../com/util/Views' +import {observer} from 'mobx-react-lite' import {UserAvatar} from '../com/util/UserAvatar' import {Text} from '../com/util/text/Text' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {UserAutocompleteViewModel} from '../../state/models/user-autocomplete-view' -import {s} from '../lib/styles' -import {MagnifyingGlassIcon} from '../lib/icons' -import {usePalette} from '../lib/hooks/usePalette' +import {useStores} from 'state/index' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {s} from 'lib/styles' +import {MagnifyingGlassIcon} from 'lib/icons' +import {WhoToFollow} from '../com/discover/WhoToFollow' +import {SuggestedPosts} from '../com/discover/SuggestedPosts' +import {ProfileCard} from '../com/profile/ProfileCard' +import {usePalette} from 'lib/hooks/usePalette' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {useAnalytics} from 'lib/analytics' -export const Search = ({navIdx, visible, params}: ScreenParams) => { +const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} +const FIVE_MIN = 5 * 60 * 1e3 + +export const Search = observer(({navIdx, visible, params}: ScreenParams) => { const pal = usePalette('default') const store = useStores() - const textInput = useRef(null) - const [query, setQuery] = useState('') - const autocompleteView = useMemo( + const {track} = useAnalytics() + const scrollElRef = React.useRef(null) + const onMainScroll = useOnMainScroll(store) + const textInput = React.useRef(null) + const [lastRenderTime, setRenderTime] = React.useState(Date.now()) // used to trigger reloads + const [isInputFocused, setIsInputFocused] = React.useState(false) + const [query, setQuery] = React.useState('') + const autocompleteView = React.useMemo( () => new UserAutocompleteViewModel(store), [store], ) const {name} = params - useEffect(() => { + const onSoftReset = () => { + scrollElRef.current?.scrollTo({x: 0, y: 0}) + } + + React.useEffect(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) + const cleanup = () => { + softResetSub.remove() + } + if (visible) { + const now = Date.now() + if (now - lastRenderTime > FIVE_MIN) { + setRenderTime(Date.now()) // trigger reload of suggestions + } store.shell.setMinimalShellMode(false) autocompleteView.setup() store.nav.setTitle(navIdx, 'Search') } - }, [store, visible, name, navIdx, autocompleteView]) + return cleanup + }, [store, visible, name, navIdx, autocompleteView, lastRenderTime]) + + const onPressMenu = () => { + track('ViewHeader:MenuButtonClicked') + store.shell.setMainMenuOpen(true) + } const onChangeQuery = (text: string) => { setQuery(text) @@ -46,87 +78,139 @@ export const Search = ({navIdx, visible, params}: ScreenParams) => { autocompleteView.setActive(false) } } - const onSelect = (handle: string) => { - textInput.current?.blur() - store.nav.navigate(`/profile/${handle}`) + const onPressCancelSearch = () => { + setQuery('') + autocompleteView.setActive(false) } return ( - - - - - - - - {query ? ( - - {autocompleteView.searchRes.map((item, i) => ( - onSelect(item.handle)}> - - - - {item.displayName || item.handle} - - @{item.handle} - + + + + + + + + + setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + onChangeText={onChangeQuery} + /> + + {query ? ( + + + Cancel + + ) : undefined} + + {query && autocompleteView.searchRes.length ? ( + <> + {autocompleteView.searchRes.map(item => ( + ))} + + ) : query && !autocompleteView.searchRes.length ? ( + + + No results found for {autocompleteView.prefix} + + + ) : isInputFocused ? ( + + + Search for users on the network + + + ) : ( + + + - ) : ( - )} - - + + + ) -} +}) const styles = StyleSheet.create({ container: { flex: 1, }, - inputContainer: { + header: { flexDirection: 'row', - paddingVertical: 16, - paddingHorizontal: 16, - borderTopWidth: 1, + alignItems: 'center', + paddingHorizontal: 12, + paddingTop: 4, + marginBottom: 14, }, - inputIcon: { - marginRight: 10, - alignSelf: 'center', + headerMenuBtn: { + width: 40, + height: 30, + marginLeft: 6, }, - input: { + headerSearchContainer: { flex: 1, - fontSize: 16, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 30, + paddingHorizontal: 12, + paddingVertical: 8, }, - - outputContainer: { + headerSearchIcon: { + marginRight: 6, + alignSelf: 'center', + }, + headerSearchInput: { flex: 1, + fontSize: 17, + }, + headerCancelBtn: { + width: 60, + paddingLeft: 10, }, - searchResult: { - flexDirection: 'row', - borderTopWidth: 1, - paddingVertical: 12, - paddingHorizontal: 16, + searchPrompt: { + textAlign: 'center', + paddingTop: 10, }, }) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index c2953b59d..94f5acd93 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -7,17 +7,20 @@ import { } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {observer} from 'mobx-react-lite' -import {useStores} from '../../state' +import * as AppInfo from 'lib/app-info' +import {useStores} from 'state/index' import {ScreenParams} from '../routes' -import {s} from '../lib/styles' +import {s} from 'lib/styles' import {ScrollView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' import {Link} from '../com/util/Link' import {Text} from '../com/util/text/Text' import * as Toast from '../com/util/Toast' import {UserAvatar} from '../com/util/UserAvatar' -import {usePalette} from '../lib/hooks/usePalette' -import {AccountData} from '../../state/models/session' +import {usePalette} from 'lib/hooks/usePalette' +import {AccountData} from 'state/models/session' +import {useAnalytics} from 'lib/analytics' +import {DeleteAccountModal} from 'state/models/shell-ui' export const Settings = observer(function Settings({ navIdx, @@ -25,8 +28,13 @@ export const Settings = observer(function Settings({ }: ScreenParams) { const pal = usePalette('default') const store = useStores() + const {screen, track} = useAnalytics() const [isSwitching, setIsSwitching] = React.useState(false) + useEffect(() => { + screen('Settings') + }, [screen]) + useEffect(() => { if (!visible) { return @@ -36,22 +44,30 @@ export const Settings = observer(function Settings({ }, [visible, store, navIdx]) const onPressSwitchAccount = async (acct: AccountData) => { + track('Settings:SwitchAccountButtonClicked') setIsSwitching(true) if (await store.session.resumeSession(acct)) { setIsSwitching(false) + store.nav.tab.fixedTabReset() Toast.show(`Signed in as ${acct.displayName || acct.handle}`) return } setIsSwitching(false) Toast.show('Sorry! We need you to enter your password.') + store.nav.tab.fixedTabReset() store.session.clear() } const onPressAddAccount = () => { + track('Settings:AddAccountButtonClicked') store.session.clear() } const onPressSignout = () => { + track('Settings:SignOutButtonClicked') store.session.logout() } + const onPressDeleteAccount = () => { + store.shell.openModal(new DeleteAccountModal()) + } return ( @@ -143,22 +159,34 @@ export const Settings = observer(function Settings({ + + Danger zone + + + Delete my account + + Developer tools - System log + System log - Storybook + Storybook + + Build version {AppInfo.appVersion} ({AppInfo.buildVersion}) + diff --git a/src/view/shell/mobile/Composer.tsx b/src/view/shell/mobile/Composer.tsx index c93931ab4..304c17725 100644 --- a/src/view/shell/mobile/Composer.tsx +++ b/src/view/shell/mobile/Composer.tsx @@ -2,9 +2,9 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {Animated, Easing, Platform, StyleSheet, View} from 'react-native' import {ComposePost} from '../../com/composer/ComposePost' -import {ComposerOpts} from '../../../state/models/shell-ui' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' -import {usePalette} from '../../lib/hooks/usePalette' +import {ComposerOpts} from 'state/models/shell-ui' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' export const Composer = observer( ({ diff --git a/src/view/shell/mobile/Menu.tsx b/src/view/shell/mobile/Menu.tsx index a7d3e2142..ceeda8c58 100644 --- a/src/view/shell/mobile/Menu.tsx +++ b/src/view/shell/mobile/Menu.tsx @@ -1,6 +1,6 @@ import React from 'react' import { - ScrollView, + Linking, StyleProp, StyleSheet, TouchableOpacity, @@ -8,40 +8,56 @@ import { ViewStyle, } from 'react-native' import {observer} from 'mobx-react-lite' -import VersionNumber from 'react-native-version-number' -import {s, colors} from '../../lib/styles' -import {useStores} from '../../../state' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {s, colors} from 'lib/styles' +import {FEEDBACK_FORM_URL} from 'lib/constants' +import {useStores} from 'state/index' import { HomeIcon, BellIcon, UserIcon, CogIcon, MagnifyingGlassIcon, -} from '../../lib/icons' +} from 'lib/icons' +import {TabPurpose, TabPurposeMainPath} from 'state/models/navigation' import {UserAvatar} from '../../com/util/UserAvatar' import {Text} from '../../com/util/text/Text' import {ToggleButton} from '../../com/util/forms/ToggleButton' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' export const Menu = observer(({onClose}: {onClose: () => void}) => { const pal = usePalette('default') const store = useStores() + const {track} = useAnalytics() // events // = const onNavigate = (url: string) => { + track('Menu:ItemClicked', {url}) + onClose() - if (url === '/notifications') { - store.nav.switchTo(1, true) + if (url === TabPurposeMainPath[TabPurpose.Notifs]) { + store.nav.switchTo(TabPurpose.Notifs, true) + } else if (url === TabPurposeMainPath[TabPurpose.Search]) { + store.nav.switchTo(TabPurpose.Search, true) } else { - store.nav.switchTo(0, true) + store.nav.switchTo(TabPurpose.Default, true) if (url !== '/') { store.nav.navigate(url) } } } + const onPressFeedback = () => { + track('Menu:FeedbackClicked') + Linking.openURL(FEEDBACK_FORM_URL) + } + // rendering // = @@ -84,8 +100,19 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => { ) + const onDarkmodePress = () => { + track('Menu:ItemClicked', {url: '/darkmode'}) + store.shell.setDarkMode(!store.shell.darkMode) + } + return ( - + onNavigate(`/profile/${store.me.handle}`)} @@ -132,7 +159,7 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => { icon={} size="28" />} label="Notifications" url="/notifications" - count={store.me.notificationCount} + count={store.me.notifications.unreadCount} /> void}) => { store.shell.setDarkMode(!store.shell.darkMode)} + onPress={onDarkmodePress} /> + - - Build version {VersionNumber.appVersion} ({VersionNumber.buildVersion} - ) - + + } + label="Feedback" + onPress={onPressFeedback} + /> - - + ) }) const styles = StyleSheet.create({ view: { flex: 1, + paddingBottom: 90, + }, + viewMinimalShell: { + paddingBottom: 50, }, section: { paddingHorizontal: 10, @@ -254,7 +292,6 @@ const styles = StyleSheet.create({ }, footer: { - paddingHorizontal: 14, - paddingVertical: 18, + paddingHorizontal: 10, }, }) diff --git a/src/view/shell/mobile/TabsSelector.tsx b/src/view/shell/mobile/TabsSelector.tsx index 921a0c85b..92854e3b6 100644 --- a/src/view/shell/mobile/TabsSelector.tsx +++ b/src/view/shell/mobile/TabsSelector.tsx @@ -12,11 +12,11 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../../com/util/text/Text' import Swipeable from 'react-native-gesture-handler/Swipeable' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import {toShareUrl} from '../../../lib/strings' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {toShareUrl} from 'lib/strings/url-helpers' import {match} from '../../routes' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' const TAB_HEIGHT = 42 diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index b0b83b12e..0b3921b7e 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -1,9 +1,8 @@ -import React, {useState, useEffect, useRef} from 'react' +import React, {useState, useEffect} from 'react' import {observer} from 'mobx-react-lite' import { Animated, Easing, - FlatList, GestureResponderEvent, StatusBar, StyleSheet, @@ -12,15 +11,18 @@ import { useColorScheme, useWindowDimensions, View, - ViewStyle, } from 'react-native' import {ScreenContainer, Screen} from 'react-native-screens' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' -import {TABS_ENABLED} from '../../../build-flags' -import {useStores} from '../../../state' -import {NavigationModel} from '../../../state/models/navigation' +import {TABS_ENABLED} from 'lib/build-flags' +import {useStores} from 'state/index' +import { + NavigationModel, + TabPurpose, + TabPurposeMainPath, +} from 'state/models/navigation' import {match, MatchResult} from '../../routes' import {Login} from '../../screens/Login' import {Menu} from './Menu' @@ -32,19 +34,21 @@ import {Text} from '../../com/util/text/Text' import {ErrorBoundary} from '../../com/util/ErrorBoundary' import {TabsSelector} from './TabsSelector' import {Composer} from './Composer' -import {s, colors} from '../../lib/styles' -import {clamp} from '../../../lib/numbers' +import {s, colors} from 'lib/styles' +import {clamp} from 'lib/numbers' import { GridIcon, GridIconSolid, HomeIcon, HomeIconSolid, + MagnifyingGlassIcon, BellIcon, BellIconSolid, -} from '../../lib/icons' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' -import {useTheme} from '../../lib/ThemeContext' -import {usePalette} from '../../lib/hooks/usePalette' +} from 'lib/icons' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' const Btn = ({ icon, @@ -59,6 +63,8 @@ const Btn = ({ | 'menu-solid' | 'home' | 'home-solid' + | 'search' + | 'search-solid' | 'bell' | 'bell-solid' notificationCount?: number @@ -76,24 +82,40 @@ const Btn = ({ iconEl = } else if (icon === 'home-solid') { iconEl = + } else if (icon === 'search') { + iconEl = ( + + ) + } else if (icon === 'search-solid') { + iconEl = ( + + ) } else if (icon === 'bell') { - const addedStyles = {position: 'relative', top: -1} as ViewStyle iconEl = ( - + ) } else if (icon === 'bell-solid') { - const addedStyles = {position: 'relative', top: -1} as ViewStyle iconEl = ( ) } else { iconEl = ( ) @@ -125,7 +147,6 @@ export const MobileShell: React.FC = observer(() => { const pal = usePalette('default') const store = useStores() const [isTabsSelectorActive, setTabsSelectorActive] = useState(false) - const scrollElRef = useRef(null) const winDim = useWindowDimensions() const [menuSwipingDirection, setMenuSwipingDirection] = useState(0) const swipeGestureInterp = useAnimatedValue(0) @@ -136,32 +157,48 @@ export const MobileShell: React.FC = observer(() => { const colorScheme = useColorScheme() const safeAreaInsets = useSafeAreaInsets() const screenRenderDesc = constructScreenRenderDesc(store.nav) + const {track} = useAnalytics() const onPressHome = () => { - if (store.shell.isMainMenuOpen) { - store.shell.setMainMenuOpen(false) + track('MobileShell:HomeButtonPressed') + if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) { + if (!store.nav.tab.canGoBack) { + store.emitScreenSoftReset() + } else { + store.nav.tab.fixedTabReset() + } + } else { + store.nav.switchTo(TabPurpose.Default, false) + if (store.nav.tab.index === 0) { + store.nav.tab.fixedTabReset() + } } - if (store.nav.tab.fixedTabPurpose === 0) { - if (store.nav.tab.current.url === '/') { - scrollElRef.current?.scrollToOffset({offset: 0}) + } + const onPressSearch = () => { + track('MobileShell:SearchButtonPressed') + if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) { + if (!store.nav.tab.canGoBack) { + store.emitScreenSoftReset() } else { store.nav.tab.fixedTabReset() } } else { - store.nav.switchTo(0, false) + store.nav.switchTo(TabPurpose.Search, false) if (store.nav.tab.index === 0) { store.nav.tab.fixedTabReset() } } } const onPressNotifications = () => { - if (store.shell.isMainMenuOpen) { - store.shell.setMainMenuOpen(false) - } - if (store.nav.tab.fixedTabPurpose === 1) { - store.nav.tab.fixedTabReset() + track('MobileShell:NotificationsButtonPressed') + if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) { + if (!store.nav.tab.canGoBack) { + store.emitScreenSoftReset() + } else { + store.nav.tab.fixedTabReset() + } } else { - store.nav.switchTo(1, false) + store.nav.switchTo(TabPurpose.Notifs, false) if (store.nav.tab.index === 0) { store.nav.tab.fixedTabReset() } @@ -178,12 +215,14 @@ export const MobileShell: React.FC = observer(() => { toValue: 1, duration: 100, useNativeDriver: true, + isInteraction: false, }).start() } else { Animated.timing(minimalShellInterp, { toValue: 0, duration: 100, useNativeDriver: true, + isInteraction: false, }).start() } }, [minimalShellInterp, store.shell.minimalShellMode]) @@ -343,8 +382,12 @@ export const MobileShell: React.FC = observer(() => { ) } - const isAtHome = store.nav.tab.current.url === '/' - const isAtNotifications = store.nav.tab.current.url === '/notifications' + const isAtHome = + store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default] + const isAtSearch = + store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search] + const isAtNotifications = + store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs] const screenBg = { backgroundColor: theme.colorScheme === 'dark' ? colors.gray7 : colors.gray1, @@ -358,7 +401,7 @@ export const MobileShell: React.FC = observer(() => { /> { params={params} navIdx={navIdx} visible={current} - scrollElRef={current ? scrollElRef : undefined} /> @@ -454,6 +496,11 @@ export const MobileShell: React.FC = observer(() => { onPress={onPressHome} onLongPress={TABS_ENABLED ? doNewTab('/') : undefined} /> + {TABS_ENABLED ? ( { icon={isAtNotifications ? 'bell-solid' : 'bell'} onPress={onPressNotifications} onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined} - notificationCount={store.me.notificationCount} + notificationCount={store.me.notifications.unreadCount} /> @@ -576,7 +623,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', borderTopWidth: 1, paddingLeft: 5, - paddingRight: 15, + paddingRight: 25, }, ctrl: { flex: 1, @@ -614,4 +661,8 @@ const styles = StyleSheet.create({ inactive: { color: colors.gray3, }, + bumpUpOnePixel: { + position: 'relative', + top: -1, + }, }) diff --git a/src/view/shell/web/Composer.tsx b/src/view/shell/web/Composer.tsx index 639040097..5006b3124 100644 --- a/src/view/shell/web/Composer.tsx +++ b/src/view/shell/web/Composer.tsx @@ -2,8 +2,8 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native' import {ComposePost} from '../../com/composer/ComposePost' -import {ComposerOpts} from '../../../state/models/shell-ui' -import {usePalette} from '../../lib/hooks/usePalette' +import {ComposerOpts} from 'state/models/shell-ui' +import {usePalette} from 'lib/hooks/usePalette' export const Composer = observer( ({ diff --git a/src/view/shell/web/DesktopLeftColumn.tsx b/src/view/shell/web/DesktopLeftColumn.tsx index 44559b6ad..d5fe45e80 100644 --- a/src/view/shell/web/DesktopLeftColumn.tsx +++ b/src/view/shell/web/DesktopLeftColumn.tsx @@ -5,9 +5,9 @@ import LinearGradient from 'react-native-linear-gradient' import {Link} from '../../com/util/Link' import {Text} from '../../com/util/text/Text' import {UserAvatar} from '../../com/util/UserAvatar' -import {s, colors, gradients} from '../../lib/styles' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' +import {s, colors, gradients} from 'lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' import { HomeIcon, HomeIconSolid, @@ -15,7 +15,7 @@ import { BellIconSolid, MagnifyingGlassIcon, CogIcon, -} from '../../lib/icons' +} from 'lib/icons' interface NavItemProps { label: string @@ -97,7 +97,7 @@ export const DesktopLeftColumn = observer(() => { } iconFilled={} /> diff --git a/src/view/shell/web/DesktopRightColumn.tsx b/src/view/shell/web/DesktopRightColumn.tsx index 2daa16f6a..22acac382 100644 --- a/src/view/shell/web/DesktopRightColumn.tsx +++ b/src/view/shell/web/DesktopRightColumn.tsx @@ -2,10 +2,10 @@ import React from 'react' import {View, StyleSheet} from 'react-native' import {Link} from '../../com/util/Link' import {Text} from '../../com/util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' -import {MagnifyingGlassIcon} from '../../lib/icons' +import {usePalette} from 'lib/hooks/usePalette' +import {MagnifyingGlassIcon} from 'lib/icons' import {LiteSuggestedFollows} from '../../com/discover/LiteSuggestedFollows' -import {s} from '../../lib/styles' +import {s} from 'lib/styles' export const DesktopRightColumn: React.FC = () => { const pal = usePalette('default') diff --git a/src/view/shell/web/index.tsx b/src/view/shell/web/index.tsx index 0eb5cf75c..7d76c01d8 100644 --- a/src/view/shell/web/index.tsx +++ b/src/view/shell/web/index.tsx @@ -1,7 +1,9 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {View, StyleSheet} from 'react-native' -import {useStores} from '../../../state' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {useStores} from 'state/index' +import {NavigationModel} from 'state/models/navigation' import {match, MatchResult} from '../../routes' import {DesktopLeftColumn} from './DesktopLeftColumn' import {DesktopRightColumn} from './DesktopRightColumn' @@ -11,8 +13,8 @@ import {ErrorBoundary} from '../../com/util/ErrorBoundary' import {Lightbox} from '../../com/lightbox/Lightbox' import {Modal} from '../../com/modals/Modal' import {Composer} from './Composer' -import {usePalette} from '../../lib/hooks/usePalette' -import {s} from '../../lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' export const WebShell: React.FC = observer(() => { const pal = usePalette('default') -- cgit 1.4.1