Compare commits

...

337 Commits

Author SHA1 Message Date
João Ferreiro 7086b4a68c Revert "hotfix"
This reverts commit cd5175c663.
2022-12-13 16:30:46 +00:00
João Ferreiro cd5175c663 hotfix 2022-12-13 16:30:10 +00:00
João Ferreiro ed56365cc0 hotfix 2022-12-13 16:13:47 +00:00
João Ferreiro afea10e5dd hotfix 2022-12-13 16:00:13 +00:00
João Ferreiro 10360ecded update lib setup 2022-12-13 15:51:25 +00:00
João Ferreiro fe239abd26 adding more register usage (utils) 2022-12-13 15:14:42 +00:00
João Ferreiro 50818d73f6 adding more register usage 2022-12-13 11:20:02 +00:00
João Ferreiro 7021ea2745 remove release flag 2022-12-12 18:07:48 +00:00
João Ferreiro 441dd29685 using register 2022-12-12 18:07:30 +00:00
João Ferreiro 89ddcdfa52 Merge branch 'main' into improve-cold-boot-time 2022-12-12 16:59:03 +00:00
João Ferreiro a4422f1819 Revert "using register"
This reverts commit e28093ec35.
2022-12-12 16:58:33 +00:00
Paul Frazee 68dd96128a More aesthetic and perf improvements to the menu drawer 2022-12-12 10:48:36 -06:00
João Ferreiro e28093ec35 using register 2022-12-12 16:30:52 +00:00
Paul Frazee 6e55149281 Fix duplicate 'end of feed' issue 2022-12-12 09:56:19 -06:00
Paul Frazee 3aca890da6 Improve aesthetic of main menu swipeout (use screen mask) 2022-12-09 11:46:49 -06:00
Paul Frazee 4798d44110 Add main menu button 2022-12-09 11:22:19 -06:00
Paul Frazee 37b2b40aa1 Show 'processing' spinner in edit profile modal 2022-12-08 16:08:15 -06:00
Paul Frazee f9e287b12c Turn the main menu into a 'drawer' instead of a screen in the history 2022-12-08 15:34:22 -06:00
Paul Frazee 96dc9e398b Switch to temporary 'fixed tabs' model for default and notifications 2022-12-08 14:21:28 -06:00
Paul Frazee 2f0157dd33 Add avatar images and fix some type signatures 2022-12-08 13:45:23 -06:00
Paul Frazee f608712308 Replace reanimated for tabs selector 2022-12-07 17:08:49 -06:00
Paul Frazee 3807f216fe Cleanup 2022-12-07 16:59:10 -06:00
Paul Frazee 6b1a5374ae Replace navigational 'back swipe' gesture with new HorzSwipe util 2022-12-07 16:56:14 -06:00
Paul Frazee 71b7158ff9 Add HorzSwipe gesture and integrate it into the ViewSelector 2022-12-07 15:51:06 -06:00
Paul Frazee 26f06d768c Switch to react-native default animation tools for composer and post controls 2022-12-06 17:43:54 -06:00
Paul Frazee 383a40bc03 Fix mention-creation and implement mentions in notifications 2022-12-06 13:47:52 -06:00
Paul Frazee 8413fccdb5 Better empty state in profiles 2022-12-06 13:09:18 -06:00
Paul Frazee 3161703d8a Ensure all feed views can refresh 2022-12-06 13:04:40 -06:00
Paul Frazee 4c097d89c5 Add build version to menu 2022-12-06 12:40:40 -06:00
Paul Frazee 0b50ae3e61 Add context to replies when appearing in the feed 2022-12-06 12:29:13 -06:00
Paul Frazee 063583bb6f Implement terms-of-service and privacy-policy links in signup 2022-12-06 11:39:46 -06:00
Paul Frazee f2da9f2d0a Update API build 2022-12-06 11:39:30 -06:00
João Ferreiro fc7fa8a354 Upload profile image (#29)
* add editable button profile picture

* add editable button cover picture

* upload profile photos (save them locally)

* rollback pbxproj changes

* rollback podfile checksum (for git only)

* move edit photos onto edit profile modal

* adjust edit icon and image cropping size

* added temporary (react state) image

* added IMAGES_ENABLED flag

* minor lint fix

* save local photos on edit profile upload (wip)

* save profile photos on profile view state (wip)

* remove unecessary computed

* save photo in state before pushing it to viewmodel

* refactor profile pictures's state

* remove unnecessary isMe prop

* removing old comments

* tweak icon size & position

* A few styling tweaks and a fix to mobx state management

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2022-12-06 10:57:15 -06:00
Paul Frazee 709a970cdc Fix to unicode zero-width characters in expanded posts 2022-12-06 10:15:59 -06:00
Paul Frazee 5d7814897c Never allow undefined values in me model 2022-12-06 10:11:45 -06:00
Paul Frazee 1e2ee001a8 Rework the 'main menu' to be a screen that's always in history 2022-12-06 10:11:32 -06:00
Paul Frazee 11423fc25c Add missing usage descriptions for ios 2022-12-06 10:08:27 -06:00
Paul Frazee 160fc31b47 Enlarge the small emoji-only replies 2022-12-05 15:40:33 -06:00
Paul Frazee 3389e0dc4a Dismiss keyboard in search on scroll 2022-12-05 15:22:23 -06:00
Paul Frazee ae8162974e Add 'copy post text' tool 2022-12-05 15:03:42 -06:00
Paul Frazee b33269a535 Better sized and more informative character counter 2022-12-05 13:59:13 -06:00
Paul Frazee d8280b6ad9 Switch to System font to fix emoji rendering 2022-12-05 13:44:24 -06:00
Paul Frazee 935cc8f675 Fix: never attempt update on 0 items 2022-12-05 13:36:13 -06:00
Paul Frazee 3bd6e9c84c Increase touch-size of the 'load new posts' button 2022-12-05 13:33:31 -06:00
Paul Frazee f1b9114af0 Ensure the UI always renders, even in bad network conditions (close #6) 2022-12-05 13:25:04 -06:00
Paul Frazee 95e5b169c8 Merge branch 'upload-image' into main 2022-12-05 11:25:33 -06:00
Paul Frazee 09791a3b3c Remove loading-placeholder animation; according to some issues, animated linear-gradients may cause crashes 2022-12-05 10:50:46 -06:00
Paul Frazee c7a5642e48 Fix 'swipe back to nothing' bug 2022-12-05 10:39:34 -06:00
Paul Frazee 6c23972620 Make 'backswipe' a little easier to trigger 2022-12-05 10:25:53 -06:00
Paul Frazee 211af98aaf Enlarge click targets for post controls and view header 2022-12-05 10:18:38 -06:00
Paul Frazee bc404572cb Hide images behind a build flag until ready 2022-12-02 10:48:57 -06:00
João Ferreiro b824ac710b Upload image in composer (#27)
* upload images in composer v1

* fix android compile

* reafctor image carousel into new component;
fix photo overlapping text in composer

* revert android changes

* further refactoring code into different components

* move show carousel out of the component

* fixing add photo using camera

* fix typescript issue; force mediatype photo

* change post test with photo attached;
remove auto linking settings

* use runInAction in getPhotos model

* react-hooks/exhaustive-deps fixes

* crop every photo;
make use of useCallback

* moving placeholder condition

* Cleanup

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2022-12-02 10:41:01 -06:00
Paul Frazee 372cd80175 Cleanup 2022-12-02 10:40:22 -06:00
João Ferreiro d0c9f95c7e moving placeholder condition 2022-11-29 18:32:07 +00:00
João Ferreiro 91a06f2390 crop every photo;
make use of useCallback
2022-11-29 18:26:16 +00:00
João Ferreiro ffd333164b react-hooks/exhaustive-deps fixes 2022-11-29 17:51:06 +00:00
João Ferreiro a6e8bb39b8 use runInAction in getPhotos model 2022-11-29 17:46:55 +00:00
João Ferreiro 8d87fbd56a change post test with photo attached;
remove auto linking settings
2022-11-29 16:45:44 +00:00
João Ferreiro a129ddba27 Merge branch 'main' into upload-image 2022-11-29 16:19:38 +00:00
João Ferreiro 211b9fc2f2 fix typescript issue; force mediatype photo 2022-11-29 16:19:15 +00:00
João Ferreiro 8b6bb6999d fixing add photo using camera 2022-11-29 16:11:17 +00:00
Paul Frazee 524cb062a2 Improvements to link and mention detection regarding surrounding punctuation 2022-11-29 10:01:57 -06:00
João Ferreiro be71989e45 move show carousel out of the component 2022-11-29 16:00:21 +00:00
João Ferreiro edb5a8ddb2 Merge branch 'main' into upload-image 2022-11-29 15:36:26 +00:00
João Ferreiro fb461c0344 further refactoring code into different components 2022-11-29 15:35:49 +00:00
Paul Frazee a8608d0170 Relax the requirement that post entities match their value 2022-11-29 09:35:10 -06:00
Paul Frazee 628f69605c Decode html entities in link embeds 2022-11-29 09:29:57 -06:00
João Ferreiro 537770b39d revert android changes 2022-11-29 15:25:32 +00:00
João Ferreiro 44eca8a550 reafctor image carousel into new component;
fix photo overlapping text in composer
2022-11-29 10:59:32 +00:00
João Ferreiro 3a79e46a4a Merge branch 'main' into upload-image 2022-11-29 10:29:19 +00:00
Paul Frazee 6f56a8c539 Improve handling of deleted posts 2022-11-28 16:37:39 -06:00
Paul Frazee 2ac28aa2de Fixes to edit profile modal 2022-11-28 14:51:49 -06:00
Paul Frazee 53c55b2d7a Fix jank in notifications loading-state rendering 2022-11-28 14:22:19 -06:00
Paul Frazee 31e0fa898a Use a persistent notifications model to improve load times of the notifications view 2022-11-28 14:19:49 -06:00
Paul Frazee 1ec46a00e2 Improve unread notification styling 2022-11-28 13:56:05 -06:00
Paul Frazee d98fbbbe4d Batch notification loads to avoid layout reflows due to async 2022-11-28 13:45:49 -06:00
João Ferreiro ef02ec9ab1 fix android compile 2022-11-28 16:56:23 +00:00
João Ferreiro 72fe122005 Merge branch 'main' into upload-image 2022-11-28 16:56:05 +00:00
João Ferreiro 4b960bc320 upload images in composer v1 2022-11-28 16:46:58 +00:00
Paul Frazee 090ea1a3f4 Slow down and enlarge post-ctrl animations (close #11) 2022-11-28 10:40:25 -06:00
Paul Frazee 983f2349e0 Fix toast positioning (close #9) 2022-11-28 10:38:16 -06:00
Paul Frazee 8af98b4a52 Set a page-size on notifications 2022-11-28 10:22:52 -06:00
Paul Frazee d821803050 Fix a couple incorrect link detections ('e.g.' and 'foo.jpg') (close #13) 2022-11-28 10:22:08 -06:00
Paul Frazee 35ead0155e Remove the 'warning color' from the character counter 2022-11-28 09:58:40 -06:00
Paul Frazee 5b908f5358 Tune post-thread expanded view: add reply prompt, fix spacing and sizing 2022-11-28 09:49:41 -06:00
Paul Frazee 099fea6624 Enlarge the 'load more posts' prompt 2022-11-28 09:30:31 -06:00
Paul Frazee a2ef0b5a68 Enlarge the view header, left align the title, and add a compose button 2022-11-28 09:29:35 -06:00
Paul Frazee 38c17e06bb Update composer to shrink fonts and use more consistent visuals; also autofocus the input 2022-11-28 09:14:04 -06:00
Paul Frazee c7c0c04b66 Increase information density and enlarge some click targets 2022-11-28 09:13:37 -06:00
Paul Frazee bc7364acca Fix 'load more' showing up incorrectly 2022-11-28 08:30:34 -06:00
Paul Frazee 1e9d265403 Add link embeds to posts 2022-11-23 17:01:00 -06:00
Paul Frazee 02689c5533 Implement a link metadata fetching util function 2022-11-23 16:29:17 -06:00
Paul Frazee d0f6da2a51 Include reposts in no-replies view of feeds 2022-11-23 16:27:49 -06:00
Paul Frazee 2e9a858e3d Remove webcrypto polyfill, as it's no longer needed 2022-11-23 15:04:44 -06:00
Paul Frazee 8e46204617 Detect links in profile bios 2022-11-23 14:53:33 -06:00
Paul Frazee b844e479a1 Add replying-to context to threads 2022-11-23 14:22:40 -06:00
Paul Frazee f7ccf14211 Ensure threads group together posts by op 2022-11-23 13:45:50 -06:00
Paul Frazee 814ddb194a Disable landscape orientation in iPhones 2022-11-23 13:34:45 -06:00
Paul Frazee d7103412e5 Fix: Adjust home TL post dedup to ensure polling works correctly 2022-11-23 13:29:26 -06:00
Paul Frazee ad4c413ae4 Show parent post in composer when replying (close #3) 2022-11-23 13:28:29 -06:00
Paul Frazee f0ed5d7c28 Fix: dont detect double dots as urls 2022-11-23 13:15:38 -06:00
Paul Frazee e8304bb77d Fix keyboard issues in search 2022-11-23 13:12:29 -06:00
Paul Frazee 5ae10f17c5 Turn composer character counter into a countdown 2022-11-23 13:07:09 -06:00
Paul Frazee 4b8de11046 Handle bsky.app links natively 2022-11-23 10:51:20 -06:00
Paul Frazee 27f94052e9 Longer shortened URLs 2022-11-23 10:44:55 -06:00
Paul Frazee 8004d038ab Add threading to post feeds 2022-11-23 10:39:28 -06:00
Paul Frazee ff9d7346ec Fallback to display name in search results 2022-11-22 16:18:10 -06:00
Paul Frazee c0298dc7b7 Implement user search 2022-11-22 16:14:58 -06:00
Paul Frazee ab0f4f8288 Keep fetching suggestions to reach a full list 2022-11-22 15:53:23 -06:00
Paul Frazee 148fa29875 Farewell, todos.txt 2022-11-22 14:31:46 -06:00
Paul Frazee f13c42c078 Add support for links with no scheme in composer 2022-11-22 14:30:35 -06:00
Paul Frazee a079605049 Fix to tests 2022-11-22 14:30:13 -06:00
Paul Frazee 56d98abe64 Shorten rendered URLs in posts 2022-11-22 13:16:40 -06:00
Paul Frazee dab5a36a58 Fix to thread reply lines (fill full space) 2022-11-22 13:04:54 -06:00
Paul Frazee 65e80556e8 Add bottom padding to threads 2022-11-22 12:58:08 -06:00
Paul Frazee b60406f960 Scroll to highlighted post in threads 2022-11-22 12:56:56 -06:00
Paul Frazee 4d2c2c5f2a Add compose prompt to home feed empty state 2022-11-22 12:29:50 -06:00
Paul Frazee deb7c1bba7 Add 'posts & replies' view to profiles 2022-11-22 12:26:53 -06:00
Paul Frazee 8b1134dc75 Turn home button into a 'go back to start of nav history' 2022-11-22 12:19:38 -06:00
Paul Frazee a83da7367c Change post prompt to 'Whats up?' 2022-11-22 12:16:08 -06:00
Paul Frazee ec3ba2ebc8 Shrink and darken the post controls a little 2022-11-22 12:15:26 -06:00
Paul Frazee ab4f828865 Shrink the footer a bit 2022-11-22 12:02:45 -06:00
Paul Frazee c72e1b6635 Refactor: create src/lib to reflect actual sharing of that code 2022-11-22 12:02:31 -06:00
Paul Frazee 4e245f8471 Merge branch 'simplify' into main 2022-11-21 18:55:08 -06:00
Paul Frazee 652362b0c2 Track rotated auth tokens 2022-11-21 16:17:40 -06:00
Paul Frazee abd6ffdbf5 Add web linking and proper share controls 2022-11-21 16:07:26 -06:00
Paul Frazee 0dc1c904ed Adjust control alignment 2022-11-20 16:29:43 -06:00
Paul Frazee a8bdb4a288 Fix the pointy edges on the arrow icons 2022-11-20 16:24:44 -06:00
Paul Frazee 9ec2712728 Move search btn into the viewheader 2022-11-20 12:25:11 -06:00
Paul Frazee 64609575e7 Add build flags and disable tabs for now 2022-11-20 12:00:40 -06:00
Paul Frazee 09411967e8 Adjust post control sizing and alignment 2022-11-20 11:45:31 -06:00
Paul Frazee fbec25412b Remove downvotes from the UI 2022-11-20 11:32:13 -06:00
Paul Frazee ffe51591e8 Replace the FAB with a compose prompt at the top of the feed 2022-11-20 11:20:08 -06:00
Paul Frazee f11ef7ba0b Dont group notifications that are an hour apart 2022-11-18 15:38:20 -06:00
Paul Frazee 0439674aa9 Fix data loss on notifications-view update 2022-11-18 14:17:26 -06:00
Paul Frazee e609f8dadb Dedup posts in the home TL 2022-11-18 14:05:19 -06:00
Paul Frazee 6af2e1d162 Add support for web links 2022-11-18 13:59:17 -06:00
Paul Frazee 35e46a5b60 Fix rendering of mentions in composer 2022-11-18 11:55:05 -06:00
Paul Frazee 14ca7602e9 Add long press newtab to footer nav 2022-11-18 11:40:12 -06:00
Paul Frazee 7c719f3af8 Fix richtext link rendering 2022-11-18 11:37:25 -06:00
Paul Frazee 547965b528 Spacing and sizing fixes 2022-11-18 11:37:12 -06:00
Paul Frazee 85a425bf3e Dont autofocus composer to avoid a bug with keyboard avoiding views 2022-11-18 11:10:29 -06:00
Paul Frazee d9d9344fcc Small sizing and spacing tweaks 2022-11-18 11:10:19 -06:00
Paul Frazee 67fb6b23da Add min height to post text 2022-11-18 10:58:48 -06:00
Paul Frazee 2a4391af97 Fix avatar click target in posts 2022-11-18 10:48:18 -06:00
Paul Frazee fec3aa2e2f Fix post thread title 2022-11-17 16:26:00 -06:00
Paul Frazee 4fcc0accc7 Add padding to the bottom of the feed to ensure compose btn doesnt cover it 2022-11-17 16:07:38 -06:00
Paul Frazee 3da90128f0 Improve reliability of screen titles 2022-11-17 16:01:29 -06:00
Paul Frazee 038556cc58 Add new tab animation 2022-11-17 15:44:54 -06:00
Paul Frazee f42ff8e4cf Add live search to autocomplete and only highlight known handles 2022-11-17 14:35:12 -06:00
Paul Frazee d073049ed2 Fix loadLatest() on home feed 2022-11-17 13:17:08 -06:00
Paul Frazee b165d076f1 Fix: correctly set visible on screens 2022-11-17 13:15:04 -06:00
Paul Frazee c16e1b8fb1 Improve perf of post rendering 2022-11-17 13:02:53 -06:00
Paul Frazee a527c1086a Invalidate profiles cache on direct load 2022-11-17 13:01:57 -06:00
Paul Frazee 6be082e728 Clear session when auth fails 2022-11-17 12:33:59 -06:00
Paul Frazee f99cdba6dd Poll periodically for new posts 2022-11-17 12:33:19 -06:00
Paul Frazee 397ace5c11 Improve coffee 2022-11-17 11:29:35 -06:00
Paul Frazee 924c76f11b Add tabs explainer and fix 'black screen' during onboard 2022-11-17 11:21:04 -06:00
Paul Frazee d875933412 Lighten the post controls 2022-11-17 11:03:38 -06:00
Paul Frazee c5a81dcc71 Fix positioning with overflows in the post meta 2022-11-17 10:56:42 -06:00
Paul Frazee 28a9fe85d5 Include tab count and move btn to center 2022-11-17 10:37:30 -06:00
Paul Frazee 29d5cef912 Open in new tab on long press 2022-11-17 10:36:57 -06:00
Paul Frazee 0e935a50e7 Avoid overlap against phone header when tabs selector is open 2022-11-17 10:24:40 -06:00
Paul Frazee 61de9aaea6 Fix header spacing for when dark mode is active 2022-11-17 10:15:48 -06:00
Paul Frazee 83a533a0dc Show bold icons in footer based on state 2022-11-16 18:04:21 -06:00
Paul Frazee 2e7da95e5b Add a fancy 'drawer' animation to the tabs selector 2022-11-16 17:18:16 -06:00
Paul Frazee 93a7667c83 Add post deletion to highlighted post in threads 2022-11-16 16:18:15 -06:00
Paul Frazee 51f78cef9a Add post deletion 2022-11-16 16:16:43 -06:00
Paul Frazee bf7581e8e3 Clear notifications count optimistically 2022-11-16 14:36:16 -06:00
Paul Frazee aa7a5964f2 Fix composer positioning 2022-11-16 14:33:01 -06:00
Paul Frazee 30c0a5f4b1 Fix input positioning for small screens 2022-11-16 14:28:33 -06:00
Paul Frazee a3c3b1cd27 Fix sizing on smaller phones 2022-11-16 14:05:21 -06:00
Paul Frazee cc775dcdc0 Nicer loading state for profiles 2022-11-16 13:32:10 -06:00
Paul Frazee 5d9c21c2a5 Reduce jank in refreshes 2022-11-16 13:18:53 -06:00
Paul Frazee 9a19607f3f Add placeholder loading states 2022-11-16 13:05:36 -06:00
Paul Frazee 3d0eeb01f8 Improve reliability of modal close 2022-11-16 12:37:46 -06:00
Paul Frazee 310ec3a574 Fix up/down vote number changes 2022-11-16 11:02:36 -06:00
Paul Frazee 96d12df021 Add animations to post controls 2022-11-16 10:58:39 -06:00
Paul Frazee f382d6f3a5 Update prod service URL 2022-11-16 10:06:57 -06:00
Paul Frazee 6de99fd964 Better invite code error message 2022-11-15 15:13:55 -06:00
Paul Frazee bc76c7ffb8 Add a server instance selector and drop env vars 2022-11-15 15:09:50 -06:00
Paul Frazee 20ed8da853 Switch to using setVote() 2022-11-15 13:19:21 -06:00
Paul Frazee aaf10bfdbf Add current user profile to header and enlarge the header 2022-11-15 13:11:01 -06:00
Paul Frazee 36b9020f36 Add compose button to notifications, profile, and search 2022-11-15 13:00:00 -06:00
Paul Frazee 5ec8fcf350 Add missing dep 2022-11-15 12:54:15 -06:00
Paul Frazee 194c6dc0fd Improve connectivity-issue error message 2022-11-15 12:54:04 -06:00
Paul Frazee aadec58087 Better loading screens 2022-11-15 12:07:41 -06:00
Paul Frazee 1b1883aa30 Improve empty states 2022-11-15 10:54:43 -06:00
Paul Frazee 020a0bc882 Improve error messages 2022-11-15 10:46:12 -06:00
Paul Frazee f2bf75e85e Add profile info caching 2022-11-14 16:56:59 -06:00
Paul Frazee 957f42698e Disable storing nav state due to bugs 2022-11-14 16:56:40 -06:00
Paul Frazee fd436dcd9b Add more info to routing 2022-11-14 16:30:06 -06:00
Paul Frazee 9e32b64d57 Restore nav state hydration 2022-11-14 16:28:58 -06:00
Paul Frazee 7d39cb5f6b Improve not found page 2022-11-14 16:27:59 -06:00
Paul Frazee a8cd419b08 Use only alphabet in generated avatar 2022-11-14 15:58:21 -06:00
Paul Frazee c20d941a77 Update all state on profile change 2022-11-14 15:55:38 -06:00
Paul Frazee 93741ac50e Make 'back swiping' a little easier 2022-11-14 15:47:56 -06:00
Paul Frazee f83739fdb9 Enforce limits on create scene as well 2022-11-14 15:19:08 -06:00
Paul Frazee daf91112f5 Fix double-posting bug 2022-11-14 15:10:34 -06:00
Paul Frazee d406b3a5c2 Fix issue causing double-loads of notifications 2022-11-14 15:02:42 -06:00
Paul Frazee 09b8ae0fed Automatically add the domain to the handle during signin 2022-11-14 14:30:59 -06:00
Paul Frazee 683a456b5c Fix empty states for scene members and memberships 2022-11-14 14:24:23 -06:00
Paul Frazee 265af0d720 Fix: scroll all the way down on home feed 2022-11-14 14:18:42 -06:00
Paul Frazee 30f6636a95 Fix overflows in post meta info 2022-11-14 14:16:31 -06:00
Paul Frazee 03980afe98 Turn off autocorrect and auto-lowercase login handle 2022-11-14 14:01:01 -06:00
Paul Frazee a3f2c28c13 Add link behaviors to search screen suggestions 2022-11-14 13:52:23 -06:00
Paul Frazee 2850887c71 Dont suggest followed users and add empty state to suggestions 2022-11-14 13:48:57 -06:00
Paul Frazee 8a403407cb Dont include self in suggested follows 2022-11-14 13:42:53 -06:00
Paul Frazee 73f1f464d8 Always fallback from displayName to handle 2022-11-14 13:39:10 -06:00
Paul Frazee 16c06cd997 Turn 'include reason' on posts into a link 2022-11-14 13:36:09 -06:00
Paul Frazee 0f1a59641a Reposition to handle scene creation keyboard view 2022-11-14 13:32:04 -06:00
Paul Frazee bd62ab2f92 Change appid to xyz.blueskyweb.app 2022-11-14 13:13:08 -06:00
Paul Frazee 4727fb25ce Improvements to the feature explainer, including intro screen and screenshot of scenes 2022-11-14 13:12:29 -06:00
Paul Frazee 0947970514 Add staging env 2022-11-11 15:02:19 -06:00
Paul Frazee 04b081bbd7 Implement profile updates 2022-11-11 14:15:43 -06:00
Paul Frazee 3a6bb19cad Add UI to remove members from scenes 2022-11-11 13:28:23 -06:00
Paul Frazee 77650f328b Implement trending 2022-11-11 13:10:05 -06:00
Paul Frazee 424a08c4a0 Handle pending invites correctly 2022-11-11 12:49:53 -06:00
Paul Frazee 18d96ce0ce Disable scene discover btn for now 2022-11-11 10:17:23 -06:00
Paul Frazee 60f4d64af6 Only suggest users in invite suggestions 2022-11-11 10:17:14 -06:00
Paul Frazee 5664ecbd0c Fixes to onboarding 2022-11-10 17:19:45 -06:00
Paul Frazee 4fa74c0d38 Fixes to account creation 2022-11-10 17:05:26 -06:00
Paul Frazee 6b3780f6d1 Fix follows 2022-11-10 16:35:13 -06:00
Paul Frazee 7d90638188 Implement scene invitation and membership controls 2022-11-10 16:30:14 -06:00
Paul Frazee 28c2591e0b More informative empty state for scenes 2022-11-09 17:34:55 -06:00
Paul Frazee 2b58550d36 Add scene creator 2022-11-09 15:57:49 -06:00
Paul Frazee 22e619d0ea Add 'you are a member' line to scene headers and small other tweaks 2022-11-09 13:48:40 -06:00
Paul Frazee a32b067057 Add creator to scene profile header 2022-11-09 13:12:03 -06:00
Paul Frazee 482f848281 Implement scene member/memberships listings 2022-11-08 16:37:21 -06:00
Paul Frazee 6f97f8a84b Implement scenes listing in main menu 2022-11-08 15:56:22 -06:00
Paul Frazee 0168efd737 Finish the upvote/downvote implementation 2022-11-08 12:14:51 -06:00
Paul Frazee bb1d0ab0d9 Add search view; factor out SuggestedFollows component; add suggested follows to search 2022-11-07 16:24:06 -06:00
Paul Frazee 0fae45e8df Add onboarding (WIP) 2022-11-07 15:35:51 -06:00
Paul Frazee 34203fcbce Reuse service address 2022-11-07 11:45:26 -06:00
Paul Frazee e64fe9d36e Sizing and spacing fixes based on on-device testing 2022-11-05 16:12:06 -05:00
Paul Frazee 11c5000b5b Unify profile follow btn colors with the rest 2022-11-05 15:07:28 -05:00
Paul Frazee 1e5046c679 Set ios app icon 2022-11-05 14:55:41 -05:00
Paul Frazee 9344f7f20d Add splash screen to ios 2022-11-05 14:52:44 -05:00
Paul Frazee b724b0d3eb Rename to bsky.app 2022-11-05 13:56:22 -05:00
Paul Frazee 6703f0c068 Replace likes with votes on post UIs 2022-11-05 13:48:45 -05:00
Paul Frazee 88345d7cb5 More profile header tweaks 2022-11-05 13:02:21 -05:00
Paul Frazee 266cd69348 Tweak profile header 2022-11-05 12:43:21 -05:00
Paul Frazee 1ae5318af0 Switch to procedurally-generated banners 2022-11-05 12:33:59 -05:00
Paul Frazee 992b79384f Add actor types to the profiles and clean up the UI 2022-11-05 11:58:48 -05:00
Paul Frazee f9fa6229b2 Update to new textslice 2022-11-04 17:56:48 -05:00
Paul Frazee 2c5b988de0 Update to the latest APIs 2022-11-04 17:39:54 -05:00
Paul Frazee feac39e657 Rework the composer to a less buggy solution 2022-11-01 14:25:41 -05:00
Paul Frazee 1fa4419da1 Remove old tabs selector modal 2022-11-01 14:00:58 -05:00
Paul Frazee f49f8b6495 Add animation to the tabs selector 2022-11-01 13:46:07 -05:00
Paul Frazee c122f8a9d7 Replace notifications icon 2022-11-01 13:45:58 -05:00
Paul Frazee 91dd25f7b1 Replace footer-nav back and forward arrows with search and notifications 2022-11-01 11:53:30 -05:00
Paul Frazee 0d1cdb7a38 Add consistent view headers 2022-11-01 11:06:43 -05:00
Paul Frazee 9238504db9 Update scene items in mainmenu to use generated avatars 2022-11-01 10:06:24 -05:00
Paul Frazee 774a3a653e Improve animation jitter in main menu 2022-11-01 10:02:24 -05:00
Paul Frazee 497155b84c Fix typo 2022-10-31 17:24:58 -05:00
Paul Frazee b03cc5862b Switch to autogenerated avis for now 2022-10-31 17:23:47 -05:00
Paul Frazee b76a6109cf WIP scene UIs 2022-10-31 12:17:58 -05:00
Paul Frazee 97d8b7cf23 Fix some useEffect() cleanup issues 2022-10-26 14:48:15 -05:00
Paul Frazee 48158e05cb Update to latest APIs 2022-10-26 14:34:47 -05:00
Paul Frazee 20890227c9 Add contacts screen 2022-10-12 18:56:10 -05:00
Paul Frazee ed86125d87 Update api nsids 2022-10-12 15:18:46 -05:00
Paul Frazee 4b8795bfea Improve main menu (animation, aesthetics) 2022-10-11 15:16:46 -05:00
Paul Frazee 07a234e0f2 Home button scrolls to top when on home page 2022-10-11 09:37:01 -05:00
Paul Frazee bf42123576 Rearrange shell buttons to merge notifications into menu and bring back forward 2022-10-11 09:19:09 -05:00
Paul Frazee 37016ccf9f Remove the firehose view from home 2022-10-11 09:18:39 -05:00
Paul Frazee 47b8b93c2c Speed up shell nav button presses 2022-10-11 09:18:24 -05:00
Paul Frazee c5f744870f Rework footer controls 2022-10-10 21:13:25 -05:00
Paul Frazee 993a273d76 Rework profile header controls 2022-10-10 21:13:10 -05:00
Paul Frazee 6ee93cdbc4 Add firehose view to home screen 2022-10-04 14:22:20 -05:00
Paul Frazee 9a57f5de5e Update to new get*Feed xrpc methods 2022-10-04 12:55:25 -05:00
Paul Frazee 94f365f564 Update the view after a post is created 2022-10-04 11:33:32 -05:00
Paul Frazee 01515de17d Implement profile-update api 2022-10-04 11:10:24 -05:00
Paul Frazee f074d6017d Implement a couple missing bits 2022-10-04 10:53:08 -05:00
Paul Frazee 5b96be04b4 Fix to mention rendering 2022-10-04 10:26:33 -05:00
Paul Frazee de7951074a Choose mention candidates from follows 2022-10-04 10:18:35 -05:00
Paul Frazee 990b3c7084 Fixes to entity extraction 2022-10-04 10:15:35 -05:00
Paul Frazee 7de013f467 Implement mentions rendering 2022-10-03 16:02:03 -05:00
Paul Frazee ba417935f0 Fix to auth of mutation ops 2022-10-03 11:17:12 -05:00
Paul Frazee a77988a984 Implement notifications 2022-10-03 10:53:36 -05:00
Paul Frazee 5b8d0957b3 Remove debug time delays 2022-09-30 11:30:18 -05:00
Paul Frazee a913745b8f Fix thread view sizing 2022-09-29 15:17:31 -05:00
Paul Frazee 2f7e3da138 Clear all state on signout 2022-09-29 13:55:07 -05:00
Paul Frazee 13ce232455 Add settings page 2022-09-29 11:59:11 -05:00
Paul Frazee a67b4847ca Light icons 2022-09-29 10:40:05 -05:00
Paul Frazee 49beea6cc8 Add profile to tabs selector modal 2022-09-29 09:24:36 -05:00
Paul Frazee ee81540c64 More blue theming 2022-09-29 09:11:48 -05:00
Paul Frazee ccd3492e1e Beta banner 2022-09-29 09:05:47 -05:00
Paul Frazee 0a37c34704 Remove top bar in the shell 2022-09-29 09:05:40 -05:00
Paul Frazee 60c2805c01 Swap new tab and share buttons in tabs modal 2022-09-29 09:05:07 -05:00
Paul Frazee debfb16520 No visual feedback on FAB to reduce jank 2022-09-29 09:04:49 -05:00
Paul Frazee 76782b6ff1 Fix feed pagination on user profiles 2022-09-28 17:02:55 -05:00
Paul Frazee 1ccb0e8123 Add reply information to feed items 2022-09-28 15:36:25 -05:00
Paul Frazee d9cf32f0ac Improve thread rendering (show reply lines) 2022-09-28 15:03:16 -05:00
Paul Frazee 521e691138 Fix to session load 2022-09-28 15:03:02 -05:00
Paul Frazee be9d89e0cc Switch to one default avi 2022-09-28 14:00:40 -05:00
Paul Frazee b93b57c83e Update api 2022-09-28 13:59:57 -05:00
Paul Frazee 6bad17e353 Add account creation 2022-09-27 14:24:47 -05:00
Paul Frazee eaabfb8250 Remove old auth code 2022-09-27 10:13:12 -05:00
Paul Frazee 1001c5b4c4 Add logout and tweak signin screen 2022-09-27 08:59:15 -05:00
Paul Frazee 2441cbfde6 Implement signin flow 2022-09-26 21:03:07 -05:00
Paul Frazee b2b6df7a7e A few client fixes 2022-09-26 13:38:37 -05:00
Paul Frazee d954f6d905 Implement unfollow, unlike, unrepost 2022-09-23 09:47:21 -05:00
Paul Frazee 3269f048b3 Replace mock-api with real api 2022-09-22 19:28:25 -05:00
Paul Frazee 5c07a74c55 Add close animation to tabs selector 2022-09-09 16:45:37 -05:00
Paul Frazee 744a21ec57 Replace tabs selector with better solution, also fix some bugs with the modal state 2022-09-09 16:20:46 -05:00
Paul Frazee c55711a1d9 Reduce the frequency of accidental visual triggers of touchable items 2022-09-09 13:24:44 -05:00
Paul Frazee ddcc804518 Fix some jank in the navigation swipes 2022-09-09 13:11:30 -05:00
Paul Frazee 56f983fd4e Disable swipe gestures in view selector for now 2022-09-09 12:10:18 -05:00
Paul Frazee d85751caf0 Make view-selector swipes easier to trigger 2022-09-09 11:58:28 -05:00
Paul Frazee 79a5064072 Make nav swipes easier to trigger 2022-09-09 11:55:53 -05:00
Paul Frazee cb083f3836 Allow nav swipes to start from anywhere on the screen 2022-09-09 11:55:31 -05:00
Paul Frazee 3677f3be4b UI sizing fixes 2022-09-09 11:29:46 -05:00
Paul Frazee fc835be1e6 Rename mobile start-point index.js to avoid build issues 2022-09-09 11:29:22 -05:00
Paul Frazee 40fdd27698 Implement autocomplete UI in composer 2022-09-08 13:39:53 -05:00
Paul Frazee d46de5b819 Add EditProfile modal 2022-09-07 16:00:25 -05:00
Paul Frazee 9627e9981d Improve navigation gesture and fix caching issue 2022-09-07 14:43:00 -05:00
Paul Frazee 5852f17bed Refactor profile to use new ViewSelector element which is reusable and now supports swipe gestures 2022-09-06 22:34:31 -05:00
Paul Frazee 80c4566baf Add 'swipe to go back' gesture 2022-09-06 17:29:26 -05:00
Paul Frazee 8b286a2179 Rework profile page to include working view selector 2022-09-06 14:26:39 -05:00
Paul Frazee 9abeb86168 Rework profile header 2022-09-05 16:57:20 -05:00
Paul Frazee 0963e67325 Rewrite the post composer as a modal 2022-09-05 14:16:48 -05:00
Paul Frazee 8dba18b67c Add post dropdown menus 2022-09-02 15:43:10 -05:00
Paul Frazee 4e56f11d06 Move SharePost modal to new system 2022-09-02 12:17:33 -05:00
Paul Frazee 39f13f01bc Implement consistent Link component 2022-09-02 11:52:33 -05:00
Paul Frazee 2ed9ea85c6 Add more robust modals controller 2022-09-02 11:51:46 -05:00
Paul Frazee 61b5a0cbe9 Rename location-menu to location-navigator and add an actual location-menu 2022-09-02 09:55:52 -05:00
Paul Frazee c3831bd799 Add accounts menu to shell 2022-09-02 09:30:21 -05:00
Paul Frazee 53deb48e2d Add location 'menu' 2022-09-01 16:06:09 -05:00
Paul Frazee 2eac309f86 Fix bug in selection of screens to cache 2022-09-01 13:30:57 -05:00
Paul Frazee 558f8010eb Implement working screen-state management and remove extraneous loads 2022-09-01 12:00:08 -05:00
Paul Frazee 36e3697fd6 Fix screen-blanking bug 2022-09-01 10:58:29 -05:00
Paul Frazee 42f1c5d45d Update post thread aesthetics 2022-08-31 21:02:12 -05:00
Paul Frazee 00c4940352 Add avi and context menu to shell topbar 2022-08-31 20:43:36 -05:00
Paul Frazee 3075ded77f Add composer FAB to home page 2022-08-31 20:09:58 -05:00
Paul Frazee 94fd697148 Aesthetic improvements 2022-08-31 20:09:47 -05:00
Paul Frazee a3df278f7d Unify the color palette 2022-08-31 17:06:44 -05:00
Paul Frazee 135a0456a2 New navigation model (#1)
* Flatten all routing into a single stack

* Replace router with custom implementation

* Add shell header and titles

* Add tab selector

* Add back/forward history menus on longpress

* Fix: don't modify state during render

* Add refresh() to navigation and reroute navigations to the current location to refresh instead of add to history

* Cache screens during navigation to maintain scroll position and improve load-time for renders
2022-08-31 14:36:50 -05:00
361 changed files with 29999 additions and 17618 deletions
-1
View File
@@ -1 +0,0 @@
REACT_APP_AUTH_LOBBY = 'http://localhost:3001'
-31
View File
@@ -10,11 +10,6 @@ Uses:
- [MobX](https://mobx.js.org/README.html)
- [Async Storage](https://github.com/react-native-async-storage/async-storage)
## TODOs
- Handle the "unauthed" state better than changing route definitions
- Currently it's possible to get a 404 if the auth state changes
## Build instructions
- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
@@ -37,34 +32,8 @@ Uses:
## Various notes
### Env vars
Set using the `.env` file or using bash.
```
REACT_APP_AUTH_LOBBY = 'http://localhost:3001'
```
### Build behaviors
The `metro.config.js` file rewrites a couple of imports. This is partly to work around missing features in Metro, and partly to patch the bundle. Affected imports include:
- ucans
- one-webcrypto
### Cryptography
For native builds, we must provide a polyfill of `webcrypto`. We use a custom native module AppSecureRandom (based on [react-native-securerandom](https://github.com/robhogan/react-native-securerandom)) for the CRNG and [msrcrypto](https://github.com/microsoft/MSR-JavaScript-Crypto) for the cryptography.
**NOTE** Keys are not currently stored securely.
### Polyfills
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes:
- webcrypto
- TextEncoder / TextDecoder
### Auth flow
The auth flow is based on a browser app which is specified by the `REACT_APP_AUTH_LOBBY` env var. The app redirects to that location with the UCAN request, and then waits for a redirect back. In the native platforms with proper support, it will do this using an in-app browser. In native without in-app browser, or in the Web platform, it will handle this with redirects. The ucan is extracted from the hash fragment of the "return url" which is provided either by the in-app browser in response or detected during initial setup in the case of redirects.
+1
View File
@@ -0,0 +1 @@
export default from '@react-native-async-storage/async-storage/jest/async-storage-mock'
+13 -13
View File
@@ -1,16 +1,16 @@
/**
* @format
*/
// /**
// * @format
// */
import 'react-native'
import React from 'react'
import App from '../src/App'
// import 'react-native'
// import React from 'react'
// import App from '../src/App'
// Note: test renderer must be required after react-native.
import renderer from 'react-test-renderer'
// // Note: test renderer must be required after react-native.
// import renderer from 'react-test-renderer'
it('renders correctly', () => {
renderer.act(() => {
renderer.create(<App />)
})
})
// it('renders correctly', () => {
// renderer.act(() => {
// renderer.create(<App />)
// })
// })
+118
View File
@@ -0,0 +1,118 @@
import {LikelyType, getLinkMeta} from '../src/lib/link-meta'
const exampleComHtml = `<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta name="description" content="An example website">
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>`
describe('getLinkMeta', () => {
const inputs = [
'',
'httpbadurl',
'https://example.com',
'https://example.com/index.html',
'https://example.com/image.png',
'https://example.com/video.avi',
'https://example.com/audio.ogg',
'https://example.com/javascript.js',
]
const outputs = [
{
error: 'Invalid URL',
likelyType: LikelyType.Other,
url: '',
},
{
error: 'Invalid URL',
likelyType: LikelyType.Other,
url: 'httpbadurl',
},
{
likelyType: LikelyType.HTML,
url: 'https://example.com',
title: 'Example Domain',
description: 'An example website',
},
{
likelyType: LikelyType.HTML,
url: 'https://example.com/index.html',
title: 'Example Domain',
description: 'An example website',
},
{
likelyType: LikelyType.Image,
url: 'https://example.com/image.png',
},
{
likelyType: LikelyType.Video,
url: 'https://example.com/video.avi',
},
{
likelyType: LikelyType.Audio,
url: 'https://example.com/audio.ogg',
},
{
likelyType: LikelyType.Other,
url: 'https://example.com/javascript.js',
},
]
it('correctly handles a set of text inputs', async () => {
for (let i = 0; i < inputs.length; i++) {
global.fetch = jest.fn().mockImplementationOnce(() => {
return new Promise((resolve, reject) => {
resolve({
ok: true,
status: 200,
text: () => exampleComHtml,
})
})
})
const input = inputs[i]
const output = await getLinkMeta(input)
expect(output).toEqual(outputs[i])
}
})
})
+319
View File
@@ -0,0 +1,319 @@
import {
extractEntities,
detectLinkables,
extractHtmlMeta,
} from '../src/lib/strings'
describe('extractEntities', () => {
const knownHandles = new Set(['handle.com', 'full123.test-of-chars'])
const inputs = [
'no mention',
'@handle.com middle end',
'start @handle.com end',
'start middle @handle.com',
'@handle.com @handle.com @handle.com',
'@full123.test-of-chars',
'not@right',
'@handle.com!@#$chars',
'@handle.com\n@handle.com',
'parenthetical (@handle.com)',
'start https://middle.com end',
'start https://middle.com/foo/bar end',
'start https://middle.com/foo/bar?baz=bux end',
'start https://middle.com/foo/bar?baz=bux#hash end',
'https://start.com/foo/bar?baz=bux#hash middle end',
'start middle https://end.com/foo/bar?baz=bux#hash',
'https://newline1.com\nhttps://newline2.com',
'start middle.com end',
'start middle.com/foo/bar end',
'start middle.com/foo/bar?baz=bux end',
'start middle.com/foo/bar?baz=bux#hash end',
'start.com/foo/bar?baz=bux#hash middle end',
'start middle end.com/foo/bar?baz=bux#hash',
'newline1.com\nnewline2.com',
'not.. a..url ..here',
'e.g.',
'something-cool.jpg',
'website.com.jpg',
'e.g./foo',
'website.com.jpg/foo',
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/ ',
'https://foo.com https://bar.com/whatever https://baz.com',
'punctuation https://foo.com, https://bar.com/whatever; https://baz.com.',
'parenthentical (https://foo.com)',
'except for https://foo.com/thing_(cool)',
]
interface Output {
type: string
value: string
noScheme?: boolean
}
const outputs: Output[][] = [
[],
[{type: 'mention', value: 'handle.com'}],
[{type: 'mention', value: 'handle.com'}],
[{type: 'mention', value: 'handle.com'}],
[
{type: 'mention', value: 'handle.com'},
{type: 'mention', value: 'handle.com'},
{type: 'mention', value: 'handle.com'},
],
[
{
type: 'mention',
value: 'full123.test-of-chars',
},
],
[],
[{type: 'mention', value: 'handle.com'}],
[
{type: 'mention', value: 'handle.com'},
{type: 'mention', value: 'handle.com'},
],
[{type: 'mention', value: 'handle.com'}],
[{type: 'link', value: 'https://middle.com'}],
[{type: 'link', value: 'https://middle.com/foo/bar'}],
[{type: 'link', value: 'https://middle.com/foo/bar?baz=bux'}],
[{type: 'link', value: 'https://middle.com/foo/bar?baz=bux#hash'}],
[{type: 'link', value: 'https://start.com/foo/bar?baz=bux#hash'}],
[{type: 'link', value: 'https://end.com/foo/bar?baz=bux#hash'}],
[
{type: 'link', value: 'https://newline1.com'},
{type: 'link', value: 'https://newline2.com'},
],
[{type: 'link', value: 'middle.com', noScheme: true}],
[{type: 'link', value: 'middle.com/foo/bar', noScheme: true}],
[{type: 'link', value: 'middle.com/foo/bar?baz=bux', noScheme: true}],
[{type: 'link', value: 'middle.com/foo/bar?baz=bux#hash', noScheme: true}],
[{type: 'link', value: 'start.com/foo/bar?baz=bux#hash', noScheme: true}],
[{type: 'link', value: 'end.com/foo/bar?baz=bux#hash', noScheme: true}],
[
{type: 'link', value: 'newline1.com', noScheme: true},
{type: 'link', value: 'newline2.com', noScheme: true},
],
[],
[],
[],
[],
[],
[],
[
{
type: 'link',
value:
'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
},
],
[
{
type: 'link',
value:
'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
},
],
[
{type: 'link', value: 'https://foo.com'},
{type: 'link', value: 'https://bar.com/whatever'},
{type: 'link', value: 'https://baz.com'},
],
[
{type: 'link', value: 'https://foo.com'},
{type: 'link', value: 'https://bar.com/whatever'},
{type: 'link', value: 'https://baz.com'},
],
[{type: 'link', value: 'https://foo.com'}],
[{type: 'link', value: 'https://foo.com/thing_(cool)'}],
]
it('correctly handles a set of text inputs', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const result = extractEntities(input, knownHandles)
if (!outputs[i].length) {
expect(result).toBeFalsy()
} else if (outputs[i].length && !result) {
expect(result).toBeTruthy()
} else if (result) {
expect(result.length).toBe(outputs[i].length)
for (let j = 0; j < outputs[i].length; j++) {
expect(result[j].type).toEqual(outputs[i][j].type)
if (outputs[i][j].noScheme) {
expect(result[j].value).toEqual(`https://${outputs[i][j].value}`)
} else {
expect(result[j].value).toEqual(outputs[i][j].value)
}
if (outputs[i]?.[j].type === 'mention') {
expect(
input.slice(result[j].index.start, result[j].index.end),
).toBe(`@${result[j].value}`)
} else {
if (!outputs[i]?.[j].noScheme) {
expect(
input.slice(result[j].index.start, result[j].index.end),
).toBe(result[j].value)
} else {
expect(
input.slice(result[j].index.start, result[j].index.end),
).toBe(result[j].value.slice('https://'.length))
}
}
}
}
}
})
})
describe('detectLinkables', () => {
const inputs = [
'no linkable',
'@start middle end',
'start @middle end',
'start middle @end',
'@start @middle @end',
'@full123.test-of-chars',
'not@right',
'@bad!@#$chars',
'@newline1\n@newline2',
'parenthetical (@handle)',
'start https://middle.com end',
'start https://middle.com/foo/bar end',
'start https://middle.com/foo/bar?baz=bux end',
'start https://middle.com/foo/bar?baz=bux#hash end',
'https://start.com/foo/bar?baz=bux#hash middle end',
'start middle https://end.com/foo/bar?baz=bux#hash',
'https://newline1.com\nhttps://newline2.com',
'start middle.com end',
'start middle.com/foo/bar end',
'start middle.com/foo/bar?baz=bux end',
'start middle.com/foo/bar?baz=bux#hash end',
'start.com/foo/bar?baz=bux#hash middle end',
'start middle end.com/foo/bar?baz=bux#hash',
'newline1.com\nnewline2.com',
'not.. a..url ..here',
'e.g.',
'e.g. real.com fake.notreal',
'something-cool.jpg',
'website.com.jpg',
'e.g./foo',
'website.com.jpg/foo',
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/ ',
'https://foo.com https://bar.com/whatever https://baz.com',
'punctuation https://foo.com, https://bar.com/whatever; https://baz.com.',
'parenthentical (https://foo.com)',
'except for https://foo.com/thing_(cool)',
]
const outputs = [
['no linkable'],
[{link: '@start'}, ' middle end'],
['start ', {link: '@middle'}, ' end'],
['start middle ', {link: '@end'}],
[{link: '@start'}, ' ', {link: '@middle'}, ' ', {link: '@end'}],
[{link: '@full123.test-of-chars'}],
['not@right'],
[{link: '@bad'}, '!@#$chars'],
[{link: '@newline1'}, '\n', {link: '@newline2'}],
['parenthetical (', {link: '@handle'}, ')'],
['start ', {link: 'https://middle.com'}, ' end'],
['start ', {link: 'https://middle.com/foo/bar'}, ' end'],
['start ', {link: 'https://middle.com/foo/bar?baz=bux'}, ' end'],
['start ', {link: 'https://middle.com/foo/bar?baz=bux#hash'}, ' end'],
[{link: 'https://start.com/foo/bar?baz=bux#hash'}, ' middle end'],
['start middle ', {link: 'https://end.com/foo/bar?baz=bux#hash'}],
[{link: 'https://newline1.com'}, '\n', {link: 'https://newline2.com'}],
['start ', {link: 'middle.com'}, ' end'],
['start ', {link: 'middle.com/foo/bar'}, ' end'],
['start ', {link: 'middle.com/foo/bar?baz=bux'}, ' end'],
['start ', {link: 'middle.com/foo/bar?baz=bux#hash'}, ' end'],
[{link: 'start.com/foo/bar?baz=bux#hash'}, ' middle end'],
['start middle ', {link: 'end.com/foo/bar?baz=bux#hash'}],
[{link: 'newline1.com'}, '\n', {link: 'newline2.com'}],
['not.. a..url ..here'],
['e.g.'],
['e.g. ', {link: 'real.com'}, ' fake.notreal'],
['something-cool.jpg'],
['website.com.jpg'],
['e.g./foo'],
['website.com.jpg/foo'],
[
'Classic article ',
{
link: 'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
},
],
[
'Classic article ',
{
link: 'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
},
' ',
],
[
{link: 'https://foo.com'},
' ',
{link: 'https://bar.com/whatever'},
' ',
{link: 'https://baz.com'},
],
[
'punctuation ',
{link: 'https://foo.com'},
', ',
{link: 'https://bar.com/whatever'},
'; ',
{link: 'https://baz.com'},
'.',
],
['parenthentical (', {link: 'https://foo.com'}, ')'],
['except for ', {link: 'https://foo.com/thing_(cool)'}],
]
it('correctly handles a set of text inputs', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const output = detectLinkables(input)
expect(output).toEqual(outputs[i])
}
})
})
describe('extractHtmlMeta', () => {
const inputs = [
'',
'nothing',
'<title>title</title>',
'<title> aSd!@#AC </title>',
'<title>\n title\n </title>',
'<meta name="title" content="meta title">',
'<meta name="description" content="meta description">',
'<meta property="og:title" content="og title">',
'<meta property="og:description" content="og description">',
'<meta property="og:image" content="https://ogimage.com/foo.png">',
'<meta property="twitter:title" content="twitter title">',
'<meta property="twitter:description" content="twitter description">',
'<meta property="twitter:image" content="https://twitterimage.com/foo.png">',
'<meta\n name="title"\n content="meta title"\n>',
]
const outputs = [
{},
{},
{title: 'title'},
{title: 'aSd!@#AC'},
{title: 'title'},
{title: 'meta title'},
{description: 'meta description'},
{title: 'og title'},
{description: 'og description'},
{image: 'https://ogimage.com/foo.png'},
{title: 'twitter title'},
{description: 'twitter description'},
{image: 'https://twitterimage.com/foo.png'},
{title: 'meta title'},
]
it('correctly handles a set of text inputs', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const output = extractHtmlMeta(input)
expect(output).toEqual(outputs[i])
}
})
})
+2 -2
View File
@@ -35,12 +35,12 @@ android_library(
android_build_config(
name = "build_config",
package = "xyz.blueskyweb.pubsq",
package = "xyz.blueskyweb.app",
)
android_resource(
name = "res",
package = "xyz.blueskyweb.pubsq",
package = "xyz.blueskyweb.app",
res = "src/main/res",
)
+1 -1
View File
@@ -135,7 +135,7 @@ android {
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "xyz.blueskyweb.pubsq"
applicationId "xyz.blueskyweb.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
+5 -5
View File
@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="xyz.blueskyweb.pubsq">
package="xyz.blueskyweb.app">
<uses-permission android:name="android.permission.INTERNET" />
@@ -26,17 +26,17 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- debug host: https://pubsq.pfrazee.com -->
<!-- debug host: https://bsky.pfrazee.com -->
<data android:scheme="https" />
<data android:host="pubsq.pfrazee.com" />
<data android:host="bsky.pfrazee.com" />
</intent-filter>
<!-- deep linking via custom links -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- debug scheme: pubsq://app -->
<data android:scheme="pubsq" />
<!-- debug scheme: bsky://app -->
<data android:scheme="bsky" />
<data android:host="app" />
</intent-filter>
</activity>
@@ -1,28 +0,0 @@
package xyz.blueskyweb.pubsq;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import java.security.SecureRandom;
import android.util.Base64;
public class AppSecureRandomModule extends ReactContextBaseJavaModule {
public AppSecureRandomModule(ReactApplicationContext context) {
super(context);
}
@ReactMethod
public void generateSecureRandomAsBase64(int length, Promise promise) {
SecureRandom secureRandom = new SecureRandom();
byte[] buffer = new byte[length];
secureRandom.nextBytes(buffer);
promise.resolve(Base64.encodeToString(buffer, Base64.NO_WRAP));
}
@Override
public String getName() {
return "AppSecureRandomModule";
}
}
@@ -1,26 +0,0 @@
package xyz.blueskyweb.pubsq;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class AppSecureRandomPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new AppSecureRandomModule(reactContext));
return modules;
}
}
@@ -1,4 +1,4 @@
package xyz.blueskyweb.pubsq;
package xyz.blueskyweb.app;
import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate;
@@ -13,7 +13,7 @@ public class MainActivity extends ReactActivity {
*/
@Override
protected String getMainComponentName() {
return "xyz.blueskyweb.pubsq";
return "xyz.blueskyweb.app";
}
/**
@@ -1,4 +1,4 @@
package xyz.blueskyweb.pubsq;
package xyz.blueskyweb.app;
import android.app.Application;
import android.content.Context;
@@ -9,7 +9,7 @@ import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.config.ReactFeatureFlags;
import com.facebook.soloader.SoLoader;
import xyz.blueskyweb.pubsq.newarchitecture.MainApplicationReactNativeHost;
import xyz.blueskyweb.app.newarchitecture.MainApplicationReactNativeHost;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
@@ -28,7 +28,6 @@ public class MainApplication extends Application implements ReactApplication {
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(new AppSecureRandomPackage());
return packages;
}
@@ -1,4 +1,4 @@
package xyz.blueskyweb.pubsq.newarchitecture;
package xyz.blueskyweb.app.newarchitecture;
import android.app.Application;
import androidx.annotation.NonNull;
@@ -19,9 +19,9 @@ import com.facebook.react.fabric.CoreComponentsRegistry;
import com.facebook.react.fabric.EmptyReactNativeConfig;
import com.facebook.react.fabric.FabricJSIModuleProvider;
import com.facebook.react.uimanager.ViewManagerRegistry;
import xyz.blueskyweb.pubsq.BuildConfig;
import xyz.blueskyweb.pubsq.newarchitecture.components.MainComponentsRegistry;
import xyz.blueskyweb.pubsq.newarchitecture.modules.MainApplicationTurboModuleManagerDelegate;
import xyz.blueskyweb.app.BuildConfig;
import xyz.blueskyweb.app.newarchitecture.components.MainComponentsRegistry;
import xyz.blueskyweb.app.newarchitecture.modules.MainApplicationTurboModuleManagerDelegate;
import java.util.ArrayList;
import java.util.List;
@@ -1,4 +1,4 @@
package xyz.blueskyweb.pubsq.newarchitecture.components;
package xyz.blueskyweb.app.newarchitecture.components;
import com.facebook.jni.HybridData;
import com.facebook.proguard.annotations.DoNotStrip;
@@ -1,4 +1,4 @@
package xyz.blueskyweb.pubsq.newarchitecture.modules;
package xyz.blueskyweb.app.newarchitecture.modules;
import com.facebook.jni.HybridData;
import com.facebook.react.ReactPackage;
+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">pubsq</string>
<string name="app_name">bsky</string>
</resources>
+1 -1
View File
@@ -4,7 +4,7 @@ module.exports = {
[
'module:react-native-dotenv',
{
// envName: 'APP_ENV',
envName: 'APP_ENV',
moduleName: '@env',
path: '.env',
blocklist: null,
View File
-5
View File
@@ -1,5 +0,0 @@
#import <React/RCTBridgeModule.h>
@interface AppSecureRandomModule : NSObject <RCTBridgeModule>
@end
-27
View File
@@ -1,27 +0,0 @@
#import "AppSecureRandomModule.h"
@implementation AppSecureRandomModule
RCT_EXPORT_MODULE();
+ (BOOL)requiresMainQueueSetup
{
return NO;
}
RCT_REMAP_METHOD(generateSecureRandomAsBase64,
withLength:(int)length
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
NSMutableData* bytes = [NSMutableData dataWithLength:length];
int result = SecRandomCopyBytes(kSecRandomDefault,length, [bytes mutableBytes]);
if (result == errSecSuccess) {
resolve([bytes base64EncodedStringWithOptions:0]);
} else {
NSError *error = [NSError errorWithDomain:@"RNSecureRandom" code:result userInfo: nil];
reject(@"randombytes_error", @"Error generating random bytes", error);
}
}
@end
+63 -17
View File
@@ -1,5 +1,7 @@
PODS:
- boost (1.76.0)
- BVLinearGradient (2.6.2):
- React-Core
- DoubleConversion (1.1.6)
- FBLazyVector (0.68.2)
- FBReactNativeSpec (0.68.2):
@@ -218,12 +220,20 @@ PODS:
- React-jsinspector (0.68.2)
- React-logger (0.68.2):
- glog
- react-native-safe-area-context (4.3.1):
- react-native-cameraroll (5.1.0):
- React-Core
- react-native-pager-view (6.0.2):
- React-Core
- react-native-safe-area-context (4.4.1):
- RCT-Folly
- RCTRequired
- RCTTypeSafety
- React
- React-Core
- ReactCommon/turbomodule/core
- react-native-splash-screen (3.3.0):
- React-Core
- react-native-version-number (0.3.6):
- React
- React-perflogger (0.68.2)
- React-RCTActionSheet (0.68.2):
- React-Core/RCTActionSheetHeaders (= 0.68.2)
@@ -289,15 +299,24 @@ PODS:
- React-jsi (= 0.68.2)
- React-logger (= 0.68.2)
- React-perflogger (= 0.68.2)
- RNCAsyncStorage (1.17.6):
- RNCAsyncStorage (1.17.10):
- React-Core
- RNCClipboard (1.10.0):
- RNCClipboard (1.11.1):
- React-Core
- RNGestureHandler (2.5.0):
- RNGestureHandler (2.6.1):
- React-Core
- RNInAppBrowser (3.6.3):
- RNImageCropPicker (0.38.1):
- React-Core
- RNReanimated (2.9.1):
- React-RCTImage
- RNImageCropPicker/QBImagePickerController (= 0.38.1)
- TOCropViewController
- RNImageCropPicker/QBImagePickerController (0.38.1):
- React-Core
- React-RCTImage
- TOCropViewController
- RNInAppBrowser (3.7.0):
- React-Core
- RNReanimated (2.10.0):
- DoubleConversion
- FBLazyVector
- FBReactNativeSpec
@@ -324,15 +343,17 @@ PODS:
- React-RCTText
- ReactCommon/turbomodule/core
- Yoga
- RNScreens (3.13.1):
- RNScreens (3.17.0):
- React-Core
- React-RCTImage
- RNSVG (12.4.0):
- RNSVG (12.4.4):
- React-Core
- TOCropViewController (2.6.1)
- Yoga (1.14.0)
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`)
@@ -352,7 +373,11 @@ DEPENDENCIES:
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)"
- react-native-pager-view (from `../node_modules/react-native-pager-view`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-splash-screen (from `../node_modules/react-native-splash-screen`)
- react-native-version-number (from `../node_modules/react-native-version-number`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
- React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
@@ -368,6 +393,7 @@ DEPENDENCIES:
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
- RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
@@ -377,10 +403,13 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- fmt
- TOCropViewController
EXTERNAL SOURCES:
boost:
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
BVLinearGradient:
:path: "../node_modules/react-native-linear-gradient"
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
FBLazyVector:
@@ -415,8 +444,16 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/jsinspector"
React-logger:
:path: "../node_modules/react-native/ReactCommon/logger"
react-native-cameraroll:
:path: "../node_modules/@react-native-camera-roll/camera-roll"
react-native-pager-view:
:path: "../node_modules/react-native-pager-view"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-splash-screen:
:path: "../node_modules/react-native-splash-screen"
react-native-version-number:
:path: "../node_modules/react-native-version-number"
React-perflogger:
:path: "../node_modules/react-native/ReactCommon/reactperflogger"
React-RCTActionSheet:
@@ -447,6 +484,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-clipboard/clipboard"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
RNImageCropPicker:
:path: "../node_modules/react-native-image-crop-picker"
RNInAppBrowser:
:path: "../node_modules/react-native-inappbrowser-reborn"
RNReanimated:
@@ -460,6 +499,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: a7c83b31436843459a1961bfd74b96033dc77234
BVLinearGradient: 34a999fda29036898a09c6a6b728b0b4189e1a44
DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662
FBLazyVector: a7a655862f6b09625d11c772296b01cd5164b648
FBReactNativeSpec: 81ce99032d5b586fddd6a38d450f8595f7e04be4
@@ -478,7 +518,11 @@ SPEC CHECKSUMS:
React-jsiexecutor: b7b553412f2ec768fe6c8f27cd6bafdb9d8719e6
React-jsinspector: c5989c77cb89ae6a69561095a61cce56a44ae8e8
React-logger: a0833912d93b36b791b7a521672d8ee89107aff1
react-native-safe-area-context: 6c12e3859b6f27b25de4fee8201cfb858432d8de
react-native-cameraroll: a40b082318eb1ecd0336a2f29d9f74b7f2c8cae8
react-native-pager-view: 592421df0259bf7a7a4fe85b74c24f3f39905605
react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f
React-perflogger: a18b4f0bd933b8b24ecf9f3c54f9bf65180f3fe6
React-RCTActionSheet: 547fe42fdb4b6089598d79f8e1d855d7c23e2162
React-RCTAnimation: bc9440a1c37b06ae9ebbb532d244f607805c6034
@@ -491,13 +535,15 @@ SPEC CHECKSUMS:
React-RCTVibration: 79040b92bfa9c3c2d2cb4f57e981164ec7ab9374
React-runtimeexecutor: b960b687d2dfef0d3761fbb187e01812ebab8b23
ReactCommon: 095366164a276d91ea704ce53cb03825c487a3f2
RNCAsyncStorage: 466b9df1a14bccda91da86e0b7d9a345d78e1673
RNCClipboard: f1736c75ab85b627a4d57587edb4b60999c4dd80
RNGestureHandler: bad495418bcbd3ab47017a38d93d290ebd406f50
RNInAppBrowser: 3ff3a3b8f458aaf25aaee879d057352862edf357
RNReanimated: 5c8c17e26787fd8984cd5accdc70fef2ca70aafd
RNScreens: 40a2cb40a02a609938137a1e0acfbf8fc9eebf19
RNSVG: 3dd44d99d1c18e1342aee4bfa53ab3f6a8c4865f
RNCAsyncStorage: 0c357f3156fcb16c8589ede67cc036330b6698ca
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
RNGestureHandler: 28ad20bf02257791f7f137b31beef34b9549f54b
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
RNReanimated: 5bdcbcc3a72aedeee7bb099604262403aa75a1e5
RNScreens: 0df01424e9e0ed7827200d6ed1087ddd06c493f9
RNSVG: ecd661f380a07ba690c9c5929c475a44f432d674
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
Yoga: 99652481fcd320aefa4a7ef90095b95acd181952
PODFILE CHECKSUM: cf94853ebcb0d8e0d027dca9ab7a4ede886a8f20
+69 -73
View File
@@ -11,11 +11,10 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
5CEAE7B7A55582F96F1D5952 /* libPods-app.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB672808307A6013805A3FE /* libPods-app.a */; };
379AA1F602EF704C84D7A02B /* libPods-app-appTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 05B90550DA1387B5C44FB80A /* libPods-app-appTests.a */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
E4BD704B285AD57E00A8FED9 /* AppSecureRandomModule.m in Sources */ = {isa = PBXBuildFile; fileRef = E4BD704A285AD57E00A8FED9 /* AppSecureRandomModule.m */; };
E4BD704C285AD57E00A8FED9 /* AppSecureRandomModule.m in Sources */ = {isa = PBXBuildFile; fileRef = E4BD704A285AD57E00A8FED9 /* AppSecureRandomModule.m */; };
FEB90D21557517F9279AECA4 /* libPods-app-appTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BAD3BC60FA05CF2D4F6F9BA2 /* libPods-app-appTests.a */; };
8896F504D7727F539A45F5E3 /* libPods-app.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FC3B8692C2BCABAF5171340D /* libPods-app.a */; };
E4BBD590292C1F5200296224 /* app.entitlements in Resources */ = {isa = PBXBuildFile; fileRef = E4437C9E28581FA7006DA9E7 /* app.entitlements */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -32,23 +31,21 @@
00E356EE1AD99517003FC87E /* appTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = appTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
00E356F21AD99517003FC87E /* appTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = appTests.m; sourceTree = "<group>"; };
03F7088487057F17D25D2F4B /* Pods-app-appTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app-appTests.release.xcconfig"; path = "Target Support Files/Pods-app-appTests/Pods-app-appTests.release.xcconfig"; sourceTree = "<group>"; };
05B90550DA1387B5C44FB80A /* libPods-app-appTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app-appTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
13B07F961A680F5B00A75B9A /* app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = app.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = app/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = app/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = app/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = app/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = app/main.m; sourceTree = "<group>"; };
19C43486502F2DE23E091FE3 /* Pods-app-appTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app-appTests.debug.xcconfig"; path = "Target Support Files/Pods-app-appTests/Pods-app-appTests.debug.xcconfig"; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = app/LaunchScreen.storyboard; sourceTree = "<group>"; };
970005155A83960D1D13380F /* Pods-app.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.release.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.release.xcconfig"; sourceTree = "<group>"; };
BAD3BC60FA05CF2D4F6F9BA2 /* libPods-app-appTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app-appTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
98E4A0BAD72B0C24AF5986D9 /* Pods-app.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.debug.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.debug.xcconfig"; sourceTree = "<group>"; };
9FA2BF4F6574C986E6B102F0 /* Pods-app.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.release.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.release.xcconfig"; sourceTree = "<group>"; };
BC19D4073A1A93D07CD74999 /* Pods-app-appTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app-appTests.release.xcconfig"; path = "Target Support Files/Pods-app-appTests/Pods-app-appTests.release.xcconfig"; sourceTree = "<group>"; };
E1EFCDF82E4E346591FBB028 /* Pods-app-appTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app-appTests.debug.xcconfig"; path = "Target Support Files/Pods-app-appTests/Pods-app-appTests.debug.xcconfig"; sourceTree = "<group>"; };
E4437C9E28581FA7006DA9E7 /* app.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = app.entitlements; path = app/app.entitlements; sourceTree = "<group>"; };
E4BD7049285AD54000A8FED9 /* AppSecureRandomModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppSecureRandomModule.h; sourceTree = "<group>"; };
E4BD704A285AD57E00A8FED9 /* AppSecureRandomModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppSecureRandomModule.m; sourceTree = "<group>"; };
ED22CAA45207BC18E75DB44B /* Pods-app.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.debug.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.debug.xcconfig"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
FCB672808307A6013805A3FE /* libPods-app.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app.a"; sourceTree = BUILT_PRODUCTS_DIR; };
FC3B8692C2BCABAF5171340D /* libPods-app.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app.a"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -56,7 +53,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FEB90D21557517F9279AECA4 /* libPods-app-appTests.a in Frameworks */,
379AA1F602EF704C84D7A02B /* libPods-app-appTests.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -64,7 +61,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5CEAE7B7A55582F96F1D5952 /* libPods-app.a in Frameworks */,
8896F504D7727F539A45F5E3 /* libPods-app.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -98,8 +95,6 @@
13B07FB61A68108700A75B9A /* Info.plist */,
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
13B07FB71A68108700A75B9A /* main.m */,
E4BD7049285AD54000A8FED9 /* AppSecureRandomModule.h */,
E4BD704A285AD57E00A8FED9 /* AppSecureRandomModule.m */,
);
name = app;
sourceTree = "<group>";
@@ -108,8 +103,8 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
FCB672808307A6013805A3FE /* libPods-app.a */,
BAD3BC60FA05CF2D4F6F9BA2 /* libPods-app-appTests.a */,
FC3B8692C2BCABAF5171340D /* libPods-app.a */,
05B90550DA1387B5C44FB80A /* libPods-app-appTests.a */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -148,10 +143,10 @@
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup;
children = (
ED22CAA45207BC18E75DB44B /* Pods-app.debug.xcconfig */,
970005155A83960D1D13380F /* Pods-app.release.xcconfig */,
19C43486502F2DE23E091FE3 /* Pods-app-appTests.debug.xcconfig */,
03F7088487057F17D25D2F4B /* Pods-app-appTests.release.xcconfig */,
98E4A0BAD72B0C24AF5986D9 /* Pods-app.debug.xcconfig */,
9FA2BF4F6574C986E6B102F0 /* Pods-app.release.xcconfig */,
E1EFCDF82E4E346591FBB028 /* Pods-app-appTests.debug.xcconfig */,
BC19D4073A1A93D07CD74999 /* Pods-app-appTests.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -163,11 +158,11 @@
isa = PBXNativeTarget;
buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "appTests" */;
buildPhases = (
F309585457BFF312EF73FD95 /* [CP] Check Pods Manifest.lock */,
420689A074E4E7426A392B09 /* [CP] Check Pods Manifest.lock */,
00E356EA1AD99517003FC87E /* Sources */,
00E356EB1AD99517003FC87E /* Frameworks */,
00E356EC1AD99517003FC87E /* Resources */,
2772CBCCE24036517E86445D /* [CP] Copy Pods Resources */,
C5618360E9363F58ED30C68E /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -183,13 +178,13 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "app" */;
buildPhases = (
4D45C29570A58E7A8A8D177D /* [CP] Check Pods Manifest.lock */,
1C451C95EB728E95B9CDD2FF /* [CP] Check Pods Manifest.lock */,
FD10A7F022414F080027D42C /* Start Packager */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
9CEFF2A5EA3D60C120442CA6 /* [CP] Copy Pods Resources */,
BC7FC72051CB585D346FAE10 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -248,6 +243,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
E4BBD590292C1F5200296224 /* app.entitlements in Resources */,
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
);
@@ -268,26 +264,9 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\nexport NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh\n";
shellScript = "set -e\n\nexport BUNDLE_COMMAND=\"ram-bundle\"\nexport NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh\n";
};
2772CBCCE24036517E86445D /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources.sh\"\n";
showEnvVarsInLog = 0;
};
4D45C29570A58E7A8A8D177D /* [CP] Check Pods Manifest.lock */ = {
1C451C95EB728E95B9CDD2FF /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -309,24 +288,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9CEFF2A5EA3D60C120442CA6 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources.sh\"\n";
showEnvVarsInLog = 0;
};
F309585457BFF312EF73FD95 /* [CP] Check Pods Manifest.lock */ = {
420689A074E4E7426A392B09 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -348,6 +310,40 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
BC7FC72051CB585D346FAE10 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources.sh\"\n";
showEnvVarsInLog = 0;
};
C5618360E9363F58ED30C68E /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources.sh\"\n";
showEnvVarsInLog = 0;
};
FD10A7F022414F080027D42C /* Start Packager */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -375,7 +371,6 @@
buildActionMask = 2147483647;
files = (
00E356F31AD99517003FC87E /* appTests.m in Sources */,
E4BD704C285AD57E00A8FED9 /* AppSecureRandomModule.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -384,7 +379,6 @@
buildActionMask = 2147483647;
files = (
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
E4BD704B285AD57E00A8FED9 /* AppSecureRandomModule.m in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -402,7 +396,7 @@
/* Begin XCBuildConfiguration section */
00E356F61AD99517003FC87E /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 19C43486502F2DE23E091FE3 /* Pods-app-appTests.debug.xcconfig */;
baseConfigurationReference = E1EFCDF82E4E346591FBB028 /* Pods-app-appTests.debug.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
@@ -430,7 +424,7 @@
};
00E356F71AD99517003FC87E /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 03F7088487057F17D25D2F4B /* Pods-app-appTests.release.xcconfig */;
baseConfigurationReference = BC19D4073A1A93D07CD74999 /* Pods-app-appTests.release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
@@ -455,16 +449,17 @@
};
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = ED22CAA45207BC18E75DB44B /* Pods-app.debug.xcconfig */;
baseConfigurationReference = 98E4A0BAD72B0C24AF5986D9 /* Pods-app.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = app/app.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = A6GRZHF6BV;
DEVELOPMENT_TEAM = B3LX46C5HS;
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
INFOPLIST_FILE = app/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -474,7 +469,7 @@
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = xyz.blueskyweb.pubsq;
PRODUCT_BUNDLE_IDENTIFIER = xyz.blueskyweb.app;
PRODUCT_NAME = app;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -484,15 +479,16 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 970005155A83960D1D13380F /* Pods-app.release.xcconfig */;
baseConfigurationReference = 9FA2BF4F6574C986E6B102F0 /* Pods-app.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = app/app.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = A6GRZHF6BV;
DEVELOPMENT_TEAM = B3LX46C5HS;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
INFOPLIST_FILE = app/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -502,7 +498,7 @@
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = xyz.blueskyweb.pubsq;
PRODUCT_BUNDLE_IDENTIFIER = xyz.blueskyweb.app;
PRODUCT_NAME = app;
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
+8 -1
View File
@@ -9,6 +9,9 @@
// universal links
#import <React/RCTLinkingManager.h>
// splash screen
#import "RNSplashScreen.h"
#if RCT_NEW_ARCH_ENABLED
#import <React/CoreModulesPlugins.h>
#import <React/RCTCxxBridgeDelegate.h>
@@ -44,7 +47,7 @@
bridge.surfacePresenter = _bridgeAdapter.surfacePresenter;
#endif
UIView *rootView = RCTAppSetupDefaultRootView(bridge, @"xyz.blueskyweb.pubsq", nil);
UIView *rootView = RCTAppSetupDefaultRootView(bridge, @"xyz.blueskyweb.app", nil);
if (@available(iOS 13.0, *)) {
rootView.backgroundColor = [UIColor systemBackgroundColor];
@@ -57,6 +60,10 @@
rootViewController.view = rootView;
self.window.rootViewController = rootViewController;
[self.window makeKeyAndVisible];
// Show the splash screen
[RNSplashScreen show];
return YES;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,46 +1,73 @@
{
"images" : [
{
"filename" : "40.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "60.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "29.png",
"idiom" : "iphone",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "58.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "87.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "80.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "120.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "57.png",
"idiom" : "iphone",
"scale" : "1x",
"size" : "57x57"
},
{
"filename" : "114.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "57x57"
},
{
"filename" : "120 1.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "180.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "splash.jpg",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "splash@2x.jpg",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "splash@3x.jpg",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

+11 -3
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>PubSQ</string>
<string>Bluesky</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -26,10 +26,10 @@
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>xyz.blueskyweb.pubsq</string>
<string>xyz.blueskyweb.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>pubsqapp</string>
<string>bskyapp</string>
</array>
</dict>
</array>
@@ -48,8 +48,12 @@
</dict>
</dict>
</dict>
<key>NSCameraUsageDescription</key>
<string>Used to take pictures and videos when composing posts, choosing avatars, and so on.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Used to upload pictures and videos when composing posts, choosing avatars, and so on.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
@@ -59,8 +63,12 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
+17 -21
View File
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
@@ -16,27 +16,17 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="app" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
<rect key="frame" x="0.0" y="202" width="375" height="43"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Powered by React Native" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
<rect key="frame" x="0.0" y="626" width="375" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" image="LaunchScreen" translatesAutoresizingMaskIntoConstraints="NO" id="3xW-LZ-8bI">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
</imageView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="bottom" secondItem="MN2-I3-ftu" secondAttribute="bottom" constant="20" id="OZV-Vh-mqD"/>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="akx-eg-2ui"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" id="i1E-0Y-4RG"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/3" constant="1" id="moa-c2-u7t"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
<constraint firstAttribute="bottom" secondItem="3xW-LZ-8bI" secondAttribute="bottom" id="Qjl-JH-apV"/>
<constraint firstItem="3xW-LZ-8bI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="c7a-bI-Hl7"/>
<constraint firstAttribute="trailing" secondItem="3xW-LZ-8bI" secondAttribute="trailing" id="vM9-Qy-zds"/>
<constraint firstItem="3xW-LZ-8bI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="yhO-am-Ve0"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
@@ -44,4 +34,10 @@
<point key="canvasLocation" x="52.173913043478265" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchScreen" width="500" height="750"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+1 -1
View File
@@ -4,7 +4,7 @@
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:pubsq.pfrazee.com</string>
<string>applinks:bsky.app</string>
</array>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
)
-49
View File
@@ -8,55 +8,6 @@ const metroResolver = require('metro-resolver')
const path = require('path')
module.exports = {
resolver: {
resolveRequest: (context, moduleName, platform) => {
// HACK
// metro doesn't support the "exports" directive in package.json
// so we have to manually fix some imports
// see https://github.com/facebook/metro/issues/670
// -prf
if (moduleName.startsWith('ucans')) {
const subpath = moduleName.split('/').slice(1)
if (subpath.length === 0) {
subpath.push('index.js')
} else {
subpath[subpath.length - 1] = `${subpath[subpath.length - 1]}.js`
}
const filePath = path.join(
context.projectRoot,
'node_modules',
'ucans',
'dist',
'cjs',
...subpath,
)
return {
type: 'sourceFile',
filePath,
}
}
// HACK
// this module has the same problem with the "exports" module
// but also we need modules to use our version of webcrypto
// so here we're routing to a module we define
// -prf
if (moduleName === 'one-webcrypto') {
return {
type: 'sourceFile',
filePath: path.join(
context.projectRoot,
'src',
'platform',
'polyfills.native.ts',
),
}
}
// default resolve
delete context.resolveRequest
return metroResolver.resolve(context, moduleName, platform)
},
},
transformer: {
getTransformOptions: async () => ({
transform: {
+23 -16
View File
@@ -1,62 +1,66 @@
{
"name": "pubsq",
"name": "bsky.app",
"version": "0.0.1",
"private": true,
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"ios": "react-native run-ios --simulator=\"iPhone 14\"",
"web": "react-scripts start",
"start": "react-native start",
"dev-pds": "node ./scripts/testing-server.mjs",
"dev-wallet": "cd node_modules/\\@adxp/auth-lobby && npm run start:authed",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
"test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"dependencies": {
"@adxp/auth": "*",
"@adxp/common": "*",
"@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#e6f9ecd510fd54fbc5af32e319342634d9446a07",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.0",
"@gorhom/bottom-sheet": "^4",
"@react-native-async-storage/async-storage": "^1.17.6",
"@react-native-camera-roll/camera-roll": "^5.1.0",
"@react-native-clipboard/clipboard": "^1.10.0",
"@react-navigation/bottom-tabs": "^6.3.1",
"@react-navigation/native": "^6.0.10",
"@react-navigation/native-stack": "^6.6.2",
"@react-navigation/stack": "^6.2.1",
"@zxing/text-encoding": "^0.9.0",
"base64-js": "^1.5.1",
"email-validator": "^2.0.4",
"he": "^1.2.0",
"lodash.chunk": "^4.2.0",
"lodash.omit": "^4.5.0",
"lru_map": "^0.4.1",
"mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0",
"react": "17.0.2",
"react-circular-progressbar": "^2.1.0",
"react-dom": "17.0.2",
"react-native": "0.68.2",
"react-native-appstate-hook": "^1.0.6",
"react-native-bundle-splitter": "^2.2.3",
"react-native-gesture-handler": "^2.5.0",
"react-native-image-crop-picker": "^0.38.1",
"react-native-inappbrowser-reborn": "^3.6.3",
"react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "^6.0.2",
"react-native-progress": "^5.0.0",
"react-native-reanimated": "^2.9.1",
"react-native-root-siblings": "^4.1.1",
"react-native-root-toast": "^3.4.0",
"react-native-safe-area-context": "^4.3.1",
"react-native-safe-area-context": "^4.4.1",
"react-native-screens": "^3.13.1",
"react-native-splash-screen": "^3.3.0",
"react-native-svg": "^12.4.0",
"react-native-tab-view": "^3.3.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-version-number": "^0.3.6",
"react-native-web": "^0.17.7",
"ucans": "0.9.1"
"tlds": "^1.234.0"
},
"devDependencies": {
"@adxp/auth-lobby": "*",
"@adxp/server": "*",
"@adxp/ws-relay": "*",
"@babel/core": "^7.12.9",
"@babel/runtime": "^7.12.5",
"@react-native-community/eslint-config": "^2.0.0",
"@types/he": "^1.1.2",
"@types/jest": "^26.0.23",
"@types/lodash.chunk": "^4.2.7",
"@types/lodash.omit": "^4.5.7",
"@types/react-native": "^0.67.3",
"@types/react-test-renderer": "^17.0.1",
@@ -77,6 +81,9 @@
},
"jest": {
"preset": "react-native",
"setupFiles": [
"./jest.js"
],
"moduleFileExtensions": [
"ts",
"tsx",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

-36
View File
@@ -1,36 +0,0 @@
import {IpldStore} from '@adxp/common'
import PDSServer from '@adxp/server/dist/server.js'
import PDSDatabase from '@adxp/server/dist/db/index.js'
import WSRelayServer from '@adxp/ws-relay/dist/index.js'
import AuthLobbyServer from '@adxp/auth-lobby'
const PDS_PORT = 2583
const AUTH_LOBBY1_PORT = 3001
const AUTH_LOBBY2_PORT = 3002
const WSR_PORT = 3005
async function start() {
console.log('Initializing...')
const db = PDSDatabase.memory()
const serverBlockstore = IpldStore.createInMemory()
await db.dropTables()
await db.createTables()
PDSServer(serverBlockstore, db, PDS_PORT)
init(AuthLobbyServer, AUTH_LOBBY1_PORT, 'Auth lobby')
if (process.argv.includes('--relay')) {
init(AuthLobbyServer, AUTH_LOBBY2_PORT, 'Auth lobby 2')
init(WSRelayServer, WSR_PORT, 'Relay server')
} else {
console.log('Include --relay to start the WS Relay and second auth lobby')
}
}
start()
function init(fn, port, name) {
const s = fn(port)
s.on('listening', () => console.log(`${name} running on port ${port}`))
s.on('error', e => console.log(`${name} failed to start:`, e))
}
+19 -8
View File
@@ -1,11 +1,13 @@
import 'react-native-url-polyfill/auto'
import React, {useState, useEffect} from 'react'
import {Linking} from 'react-native'
import {RootSiblingParent} from 'react-native-root-siblings'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {whenWebCrypto} from './platform/polyfills.native'
import SplashScreen from 'react-native-splash-screen'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import * as view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import * as Routes from './view/routes'
import {MobileShell} from './view/shell/mobile'
function App() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
@@ -14,12 +16,19 @@ function App() {
// init
useEffect(() => {
whenWebCrypto
.then(() => {
view.setup()
return setupState()
view.setup()
setupState().then(store => {
setRootStore(store)
SplashScreen.hide()
Linking.getInitialURL().then((url: string | null) => {
if (url) {
store.nav.handleLink(url)
}
})
.then(setRootStore)
Linking.addEventListener('url', ({url}) => {
store.nav.handleLink(url)
})
})
}, [])
// show nothing prior to init
@@ -31,7 +40,9 @@ function App() {
<GestureHandlerRootView style={{flex: 1}}>
<RootSiblingParent>
<RootStoreProvider value={rootStore}>
<Routes.Root />
<SafeAreaProvider>
<MobileShell />
</SafeAreaProvider>
</RootStoreProvider>
</RootSiblingParent>
</GestureHandlerRootView>
+3 -3
View File
@@ -1,8 +1,8 @@
import React, {useState, useEffect} from 'react'
import * as view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import * as Routes from './view/routes'
import Toast from './view/com/util/Toast'
import {DesktopWebShell} from './view/shell/desktop-web'
import Toast from 'react-native-root-toast'
function App() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
@@ -22,7 +22,7 @@ function App() {
return (
<RootStoreProvider value={rootStore}>
<Routes.Root />
<DesktopWebShell />
<Toast.ToastContainer />
</RootStoreProvider>
)
+2 -2
View File
@@ -1,4 +1,4 @@
{
"name": "xyz.blueskyweb.pubsq",
"displayName": "pubsq"
"name": "xyz.blueskyweb.app",
"displayName": "Bluesky"
}
+3
View File
@@ -0,0 +1,3 @@
export const LOGIN_INCLUDE_DEV_SERVERS = true
export const TABS_ENABLED = false
export const IMAGES_ENABLED = false
-8
View File
@@ -1,8 +0,0 @@
// @ts-ignore types not available -prf
import {REACT_APP_AUTH_LOBBY} from '@env'
if (typeof REACT_APP_AUTH_LOBBY !== 'string') {
throw new Error('ENV: No auth lobby provided')
}
export const AUTH_LOBBY = REACT_APP_AUTH_LOBBY
-5
View File
@@ -1,5 +0,0 @@
if (typeof process.env.REACT_APP_AUTH_LOBBY !== 'string') {
throw new Error('ENV: No auth lobby provided')
}
export const AUTH_LOBBY = process.env.REACT_APP_AUTH_LOBBY
+4
View File
@@ -0,0 +1,4 @@
export function isNetworkError(e: unknown) {
const str = String(e)
return str.includes('Aborted') || str.includes('Network request failed')
}
+1285
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
export function clamp(v: number, min: number, max: number): number {
return Math.min(max, Math.max(min, v))
}
+317
View File
@@ -0,0 +1,317 @@
import {AtUri} from '../third-party/uri'
import {Entity} from '../third-party/api/src/client/types/app/bsky/feed/post'
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<string>,
): Entity[] | undefined {
let match
let ents: Entity[] = []
{
// mentions
const re = /(^|\s|\()(@)([a-zA-Z0-9\.-]+)(\b)/dg
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
}
ents.push({
type: 'mention',
value: match[3],
index: {
start: match.indices[2][0], // skip the (^|\s) but include the '@'
end: match.indices[3][1],
},
})
}
}
{
// links
const re =
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/dgm
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 index = {
start: match.indices[2][0], // skip the (^|\s)
end: match.indices[2][1],
}
{
// 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|\()(?<domain>[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): string {
str = str || ''
if (str.length > len) {
return str.slice(0, len)
}
return str
}
export function cleanError(str: string): string {
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.log('Unexpected error in convertBskyAppUrlIfNeeded()', e)
}
}
return url
}
const htmlTitleRegex = /<title>([^<]+)<\/title>/i
export function extractHtmlMeta(html: string): Record<string, string> {
const res: Record<string, string> = {}
{
const match = htmlTitleRegex.exec(html)
if (match) {
res.title = match[1].trim()
}
}
{
let metaMatch
let propMatch
const metaRe = /<meta[\s]([^>]+)>/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
}
}
}
return res
}
-53
View File
@@ -1,53 +0,0 @@
import {Linking} from 'react-native'
import * as auth from '@adxp/auth'
import * as ucan from 'ucans'
import {InAppBrowser} from 'react-native-inappbrowser-reborn'
import {isWeb} from '../platform/detection'
import {extractHashFragment, makeAppUrl} from '../platform/urls'
import {ReactNativeStore, parseUrlForUcan} from '../state/lib/auth'
import * as env from '../env'
export async function requestAppUcan(
authStore: ReactNativeStore,
scope: ucan.Capability,
) {
const did = await authStore.getDid()
const returnUrl = makeAppUrl()
const fragment = auth.requestAppUcanHashFragment(did, scope, returnUrl)
const url = `${env.AUTH_LOBBY}#${fragment}`
if (isWeb) {
// @ts-ignore window is defined -prf
window.location.href = url
return false
}
if (await InAppBrowser.isAvailable()) {
// use in-app browser
const res = await InAppBrowser.openAuth(url, returnUrl, {
// iOS Properties
ephemeralWebSession: false,
// Android Properties
showTitle: false,
enableUrlBarHiding: true,
enableDefaultShare: false,
})
if (res.type === 'success' && res.url) {
const fragment = extractHashFragment(res.url)
if (fragment) {
const ucan = await parseUrlForUcan(fragment)
if (ucan) {
await authStore.addUcan(ucan)
return true
}
}
} else {
console.log('Not completed', res)
return false
}
} else {
// use system browser
Linking.openURL(url)
}
return true
}
-19
View File
@@ -1,19 +0,0 @@
import * as auth from '@adxp/auth'
import * as ucan from 'ucans'
import {makeAppUrl} from '../platform/urls'
import {ReactNativeStore} from '../state/lib/auth'
import * as env from '../env'
export async function requestAppUcan(
authStore: ReactNativeStore,
scope: ucan.Capability,
) {
const did = await authStore.getDid()
const returnUrl = makeAppUrl()
const fragment = auth.requestAppUcanHashFragment(did, scope, returnUrl)
const url = `${env.AUTH_LOBBY}#${fragment}`
// @ts-ignore window is defined -prf
window.location.href = url
return false
}
-29
View File
@@ -1,30 +1 @@
import {NativeModules} from 'react-native'
const {AppSecureRandomModule} = NativeModules
import {toByteArray} from 'base64-js'
// @ts-ignore we dont have types for this -prf
import crypto from '../third-party/msrcrypto'
import '@zxing/text-encoding' // TextEncoder / TextDecoder
async function generateSecureRandom(bytes: number) {
return toByteArray(
await AppSecureRandomModule.generateSecureRandomAsBase64(bytes),
)
}
export const whenWebCrypto = new Promise(async (resolve, reject) => {
try {
const bytes = await generateSecureRandom(48)
crypto.initPrng(Array.from(bytes))
// @ts-ignore global.window exists -prf
if (!global.window.crypto) {
// @ts-ignore global.window exists -prf
global.window.crypto = crypto
}
resolve(true)
} catch (e: any) {
reject(e)
}
})
export const webcrypto = crypto
+2 -2
View File
@@ -3,9 +3,9 @@ import {isIOS, isAndroid, isNative, isWeb} from './detection'
export function makeAppUrl(path = '') {
if (isIOS) {
return `pubsqapp://${path}`
return `bskyapp://${path}`
} else if (isAndroid) {
return `pubsq://app${path}`
return `bsky://app${path}`
} else {
// @ts-ignore window exists -prf
return `${window.location.origin}${path}`
+34 -21
View File
@@ -1,21 +1,24 @@
import {autorun} from 'mobx'
import {AdxClient, blueskywebSchemas} from '@adxp/mock-api'
import {sessionClient as AtpApi} from '../third-party/api'
import type {SessionServiceClient} from '../third-party/api/src/index'
import {RootStoreModel} from './models/root-store'
import * as libapi from './lib/api'
import * as storage from './lib/storage'
// import * as auth from './auth' TODO
export const LOCAL_DEV_SERVICE = 'http://localhost:2583'
export const STAGING_SERVICE = 'https://pds.staging.bsky.dev'
export const PROD_SERVICE = 'https://bsky.social'
export const DEFAULT_SERVICE = PROD_SERVICE
const ROOT_STATE_STORAGE_KEY = 'root'
const STATE_FETCH_INTERVAL = 15e3
export async function setupState() {
let rootStore: RootStoreModel
let data: any
const api = new AdxClient({
pds: 'http://localhost',
schemas: blueskywebSchemas,
})
await libapi.setup(api)
libapi.doPolyfill()
const api = AtpApi.service(DEFAULT_SERVICE) as SessionServiceClient
rootStore = new RootStoreModel(api)
try {
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
@@ -24,26 +27,36 @@ export async function setupState() {
console.error('Failed to load state from storage', e)
}
console.log('Initial hydrate', rootStore.me)
rootStore.session
.connect()
.then(() => {
console.log('Session connected', rootStore.me)
return rootStore.fetchStateUpdate()
})
.catch(e => {
console.log('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)
}
})
// track changes & save to storage
autorun(() => {
const snapshot = rootStore.serialize()
console.log('saving', snapshot)
storage.save(ROOT_STATE_STORAGE_KEY, snapshot)
})
// TODO
rootStore.session.setAuthed(true)
// if (env.authStore) {
// const isAuthed = await auth.isAuthed(env.authStore)
// rootStore.session.setAuthed(isAuthed)
// // handle redirect from auth
// if (await auth.initialLoadUcanCheck(env.authStore)) {
// rootStore.session.setAuthed(true)
// }
// }
await rootStore.me.load()
console.log(rootStore.me)
// periodic state fetch
setInterval(() => {
rootStore.fetchStateUpdate()
}, STATE_FETCH_INTERVAL)
return rootStore
}
+179 -299
View File
@@ -4,332 +4,212 @@
*/
// import {ReactNativeStore} from './auth'
import {
AdxClient,
AdxRepoClient,
AdxRepoCollectionClient,
AdxUri,
bsky,
SchemaOpt,
ListRecordsResponseValidated,
GetRecordResponseValidated,
} from '@adxp/mock-api'
import * as storage from './storage'
import {postTexts} from './mock-data/post-texts'
import {replyTexts} from './mock-data/reply-texts'
import {sessionClient as AtpApi} from '../../third-party/api'
import * as Profile from '../../third-party/api/src/client/types/app/bsky/actor/profile'
import * as Post from '../../third-party/api/src/client/types/app/bsky/feed/post'
import {AtUri} from '../../third-party/uri'
import {APP_BSKY_GRAPH} from '../../third-party/api'
import {RootStoreModel} from '../models/root-store'
import {extractEntities} from '../../lib/strings'
export async function setup(adx: AdxClient) {
await adx.setupMock(
() => storage.load('mock-root'),
async root => {
await storage.save('mock-root', root)
},
() => generateMockData(adx),
)
const TIMEOUT = 10e3 // 10s
export function doPolyfill() {
AtpApi.xrpc.fetch = fetchHandler
}
export async function post(
adx: AdxClient,
user: string,
store: RootStoreModel,
text: string,
replyToUri?: string,
replyTo?: Post.ReplyRef,
knownHandles?: Set<string>,
) {
let reply
if (replyToUri) {
const replyToUrip = new AdxUri(replyToUri)
const parentPost = await adx
.repo(replyToUrip.host, false)
.collection(replyToUrip.collection)
.get('Post', replyToUrip.recordKey)
if (replyTo) {
const replyToUrip = new AtUri(replyTo.uri)
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 || parentPost.uri,
parent: parentPost.uri,
root: parentPost.value.reply?.root || parentRef,
parent: parentRef,
}
}
}
return await adx
.repo(user, true)
.collection('blueskyweb.xyz:Posts')
.create('Post', {
$type: 'blueskyweb.xyz:Post',
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
}
}
}
return await store.api.app.bsky.feed.post.create(
{did: store.me.did || ''},
{
text,
reply,
entities,
createdAt: new Date().toISOString(),
})
},
)
}
export async function like(adx: AdxClient, user: string, uri: string) {
return await adx
.repo(user, true)
.collection('blueskyweb.xyz:Likes')
.create('Like', {
$type: 'blueskyweb.xyz:Like',
subject: uri,
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 unlike(adx: AdxClient, user: string, uri: string) {
const coll = adx.repo(user, true).collection('blueskyweb.xyz:Likes')
const numDels = await deleteWhere(coll, 'Like', record => {
return record.value.subject === uri
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,
})
return numDels > 0
}
export async function repost(adx: AdxClient, user: string, uri: string) {
return await adx
.repo(user, true)
.collection('blueskyweb.xyz:Posts')
.create('Repost', {
$type: 'blueskyweb.xyz:Repost',
subject: uri,
createdAt: new Date().toISOString(),
})
}
export async function unrepost(adx: AdxClient, user: string, uri: string) {
const coll = adx.repo(user, true).collection('blueskyweb.xyz:Posts')
const numDels = await deleteWhere(coll, 'Repost', record => {
return record.value.subject === uri
})
return numDels > 0
}
export async function follow(
adx: AdxClient,
user: string,
subject: {did: string; name: string},
store: RootStoreModel,
subjectDid: string,
subjectDeclarationCid: string,
) {
return await adx
.repo(user, true)
.collection('blueskyweb.xyz:Follows')
.create('Follow', {
$type: 'blueskyweb.xyz:Follow',
subject,
createdAt: new Date().toISOString(),
})
}
export async function unfollow(
adx: AdxClient,
user: string,
subject: {did: string},
) {
const coll = adx.repo(user, true).collection('blueskyweb.xyz:Follows')
const numDels = await deleteWhere(coll, 'Follow', record => {
return record.value.subject.did === subject.did
})
return numDels > 0
}
type WherePred = (_record: GetRecordResponseValidated) => Boolean
async function deleteWhere(
coll: AdxRepoCollectionClient,
schema: SchemaOpt,
cond: WherePred,
) {
const toDelete: string[] = []
await iterateAll(coll, schema, record => {
if (cond(record)) {
toDelete.push(record.key)
}
})
for (const key of toDelete) {
await coll.del(key)
}
return toDelete.length
}
type IterateAllCb = (_record: GetRecordResponseValidated) => void
async function iterateAll(
coll: AdxRepoCollectionClient,
schema: SchemaOpt,
cb: IterateAllCb,
) {
let cursor
let res: ListRecordsResponseValidated
do {
res = await coll.list(schema, {after: cursor, limit: 100})
for (const record of res.records) {
if (record.valid) {
cb(record)
cursor = record.key
}
}
} while (res.records.length === 100)
}
// TEMPORARY
// mock api config
// =======
function* dateGen() {
let start = 1657846031914
while (true) {
yield new Date(start).toISOString()
start += 1e3
}
}
const date = dateGen()
function repo(adx: AdxClient, didOrName: string) {
const userDb = adx.mockDb.getUser(didOrName)
if (!userDb) throw new Error(`User not found: ${didOrName}`)
return adx.mainPds.repo(userDb.did, userDb.writable)
}
export async function generateMockData(adx: AdxClient) {
const rand = (n: number) => Math.floor(Math.random() * n)
const picka = <T>(arr: Array<T>): T => {
if (arr.length) {
return arr[rand(arr.length)] || arr[0]
}
throw new Error('Not found')
}
await adx.mockDb.addUser({name: 'alice.com', writable: true})
await adx.mockDb.addUser({name: 'bob.com', writable: true})
await adx.mockDb.addUser({name: 'carla.com', writable: true})
const alice = repo(adx, 'alice.com')
const bob = repo(adx, 'bob.com')
const carla = repo(adx, 'carla.com')
const repos = [alice, bob, carla]
await alice.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', {
$type: 'blueskyweb.xyz:Profile',
displayName: 'Alice',
description: 'Test user 1',
})
await bob.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', {
$type: 'blueskyweb.xyz:Profile',
displayName: 'Bob',
description: 'Test user 2',
})
await carla.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', {
$type: 'blueskyweb.xyz:Profile',
displayName: 'Carla',
description: 'Test user 3',
})
// everybody follows everybody
const follow = async (who: AdxRepoClient, subjectName: string) => {
const subjectDb = adx.mockDb.getUser(subjectName)
return who.collection('blueskyweb.xyz:Follows').create('Follow', {
$type: 'blueskyweb.xyz:Follow',
return await store.api.app.bsky.graph.follow.create(
{did: store.me.did || ''},
{
subject: {
did: subjectDb?.did,
name: subjectDb?.name,
did: subjectDid,
declarationCid: subjectDeclarationCid,
},
createdAt: date.next().value,
})
}
await follow(alice, 'bob.com')
await follow(alice, 'carla.com')
await follow(bob, 'alice.com')
await follow(bob, 'carla.com')
await follow(carla, 'alice.com')
await follow(carla, 'bob.com')
createdAt: new Date().toISOString(),
},
)
}
// a set of posts and reposts
const posts: {uri: string}[] = []
for (let i = 0; i < postTexts.length; i++) {
const author = picka(repos)
posts.push(
await author.collection('blueskyweb.xyz:Posts').create('Post', {
$type: 'blueskyweb.xyz:Post',
text: postTexts[i],
createdAt: date.next().value,
}),
)
if (rand(10) === 0) {
await picka(repos)
.collection('blueskyweb.xyz:Posts')
.create('Repost', {
$type: 'blueskyweb.xyz:Repost',
subject: picka(posts).uri,
createdAt: date.next().value,
})
}
}
// a set of replies
for (let i = 0; i < 100; i++) {
const targetUri = picka(posts).uri
const urip = new AdxUri(targetUri)
const target = await adx.mainPds
.repo(urip.host, true)
.collection(urip.collection)
.get('Post', urip.recordKey)
const targetRecord = target.value as bsky.Post.Record
const author = picka(repos)
posts.push(
await author.collection('blueskyweb.xyz:Posts').create('Post', {
$type: 'blueskyweb.xyz:Post',
text: picka(replyTexts),
reply: {
root: targetRecord.reply ? targetRecord.reply.root : target.uri,
parent: target.uri,
},
createdAt: date.next().value,
}),
)
}
// a set of likes
for (const post of posts) {
for (const repo of repos) {
if (rand(3) === 0) {
await repo.collection('blueskyweb.xyz:Likes').create('Like', {
$type: 'blueskyweb.xyz:Like',
subject: post.uri,
createdAt: date.next().value,
})
}
}
}
// give alice 3 badges, 2 from bob and 2 from carla, with one ignored
const inviteBadge = await bob
.collection('blueskyweb.xyz:Badges')
.create('Badge', {
$type: 'blueskyweb.xyz:Badge',
subject: {did: alice.did, name: 'alice.com'},
assertion: {type: 'invite'},
createdAt: date.next().value,
})
const techTagBadge1 = await bob
.collection('blueskyweb.xyz:Badges')
.create('Badge', {
$type: 'blueskyweb.xyz:Badge',
subject: {did: alice.did, name: 'alice.com'},
assertion: {type: 'tag', tag: 'tech'},
createdAt: date.next().value,
})
const techTagBadge2 = await carla
.collection('blueskyweb.xyz:Badges')
.create('Badge', {
$type: 'blueskyweb.xyz:Badge',
subject: {did: alice.did, name: 'alice.com'},
assertion: {type: 'tag', tag: 'tech'},
createdAt: date.next().value,
})
await bob.collection('blueskyweb.xyz:Badges').create('Badge', {
$type: 'blueskyweb.xyz:Badge',
subject: {did: alice.did, name: 'alice.com'},
assertion: {type: 'employee'},
createdAt: date.next().value,
})
await alice.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', {
$type: 'blueskyweb.xyz:Profile',
displayName: 'Alice',
description: 'Test user 1',
badges: [
{uri: inviteBadge.uri},
{uri: techTagBadge1.uri},
{uri: techTagBadge2.uri},
],
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,
})
}
export async function inviteToScene(
store: RootStoreModel,
sceneDid: string,
subjectDid: string,
subjectDeclarationCid: string,
): Promise<string> {
const res = await store.api.app.bsky.graph.assertion.create(
{
did: sceneDid,
},
{
subject: {
did: subjectDid,
declarationCid: subjectDeclarationCid,
},
assertion: APP_BSKY_GRAPH.AssertMember,
createdAt: new Date().toISOString(),
},
)
return res.uri
}
interface Confirmation {
originator: {
did: string
declarationCid: string
}
assertion: {
uri: string
cid: string
}
}
export async function acceptSceneInvite(
store: RootStoreModel,
details: Confirmation,
): Promise<string> {
const res = await store.api.app.bsky.graph.confirmation.create(
{
did: store.me.did || '',
},
{
...details,
createdAt: new Date().toISOString(),
},
)
return res.uri
}
interface FetchHandlerResponse {
status: number
headers: Record<string, string>
body: ArrayBuffer | undefined
}
async function fetchHandler(
reqUri: string,
reqMethod: string,
reqHeaders: Record<string, string>,
reqBody: any,
): Promise<FetchHandlerResponse> {
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:'))
) {
// 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<string, string> = {}
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,
}
}
-116
View File
@@ -1,116 +0,0 @@
import * as auth from '@adxp/auth'
import * as ucan from 'ucans'
import {
getInitialURL,
extractHashFragment,
clearHash,
} from '../../platform/urls'
import * as authFlow from '../../platform/auth-flow'
import * as storage from './storage'
const SCOPE = auth.writeCap(
'did:key:z6MkfRiFMLzCxxnw6VMrHK8pPFt4QAHS3jX3XM87y9rta6kP',
'did:example:microblog',
)
export async function isAuthed(authStore: ReactNativeStore) {
return await authStore.hasUcan(SCOPE)
}
export async function logout(authStore: ReactNativeStore) {
await authStore.reset()
}
export async function parseUrlForUcan(fragment: string) {
try {
return await auth.parseLobbyResponseHashFragment(fragment)
} catch (err) {
return undefined
}
}
export async function initialLoadUcanCheck(authStore: ReactNativeStore) {
let wasAuthed = false
const fragment = extractHashFragment(await getInitialURL())
if (fragment) {
const ucan = await parseUrlForUcan(fragment)
if (ucan) {
await authStore.addUcan(ucan)
wasAuthed = true
clearHash()
}
}
return wasAuthed
}
export async function requestAppUcan(authStore: ReactNativeStore) {
return authFlow.requestAppUcan(authStore, SCOPE)
}
export class ReactNativeStore extends auth.AuthStore {
private keypair: ucan.EdKeypair
private ucanStore: ucan.Store
constructor(keypair: ucan.EdKeypair, ucanStore: ucan.Store) {
super()
this.keypair = keypair
this.ucanStore = ucanStore
}
static async load(): Promise<ReactNativeStore> {
const keypair = await ReactNativeStore.loadOrCreateKeypair()
const storedUcans = await ReactNativeStore.getStoredUcanStrs()
const ucanStore = await ucan.Store.fromTokens(storedUcans)
return new ReactNativeStore(keypair, ucanStore)
}
static async loadOrCreateKeypair(): Promise<ucan.EdKeypair> {
const storedKey = await storage.loadString('adxKey')
if (storedKey) {
return ucan.EdKeypair.fromSecretKey(storedKey)
} else {
// @TODO: again just stand in since no actual root keys
const keypair = await ucan.EdKeypair.create({exportable: true})
storage.saveString('adxKey', await keypair.export())
return keypair
}
}
static async getStoredUcanStrs(): Promise<string[]> {
const storedStr = await storage.loadString('adxUcans')
if (!storedStr) {
return []
}
return storedStr.split(',')
}
static setStoredUcanStrs(ucans: string[]): void {
storage.saveString('adxUcans', ucans.join(','))
}
protected async getKeypair(): Promise<ucan.EdKeypair> {
return this.keypair
}
async addUcan(token: ucan.Chained): Promise<void> {
this.ucanStore.add(token)
const storedUcans = await ReactNativeStore.getStoredUcanStrs()
ReactNativeStore.setStoredUcanStrs([...storedUcans, token.encoded()])
}
async getUcanStore(): Promise<ucan.Store> {
return this.ucanStore
}
async clear(): Promise<void> {
storage.clear()
}
async reset(): Promise<void> {
this.clear()
this.keypair = await ReactNativeStore.loadOrCreateKeypair()
this.ucanStore = await ucan.Store.fromTokens([])
}
}
-147
View File
@@ -1,147 +0,0 @@
// I just scraped some of my twitter timeline into here
// hope nobody minds
// -prf
export const postTexts = [
'last call! share your most offbeat home idea and you could get $100,000 USD to build it. applications to the OMG! fund are open until july 22. for rules and how to apply→ http://airbnb.com/omgfund?twclid=2-6f25jsvulsw1bpglil5jvkku0',
'Great to get some quality time with @DavidJWest #Agile2022',
'vive la sybil résistance',
'Where does one mint on @tezos these days?',
'Tame the day with the soothing and luxurious all-new 2022 INFINITI QX60.',
"In case you're wondering how it's going in the UK, here's the person most likely to be our next PM:",
'Really thought I was watching an SNL clip',
'Mediocrity is like a magnet pulling organizations toward it. You can only avoid it by constant effort.',
'Nobody in the replies talking about RegCF, DAOs, etc…\n' +
'\n' +
'The innovation is non, perhaps contra-hierarchical.',
'There are times when I think DevOps is just group therapy for system administrators.',
'Is DevOps still relevant?\n' +
'\n' +
'Join me tomorrow, at 11:00 AM PDT, to discuss the state of DevOps with @nathenharvey, as we attempt to answer that question. https://twitter.com/i/spaces/1dRKZlMPbWaJB…',
'trash is love. trash is life.',
'I want you guys to know Ive done it. Ive killed the legendary Michael Myers with a series of good stabs. No one could survive it. Hell, I even checked to be sure he was dead. Anyone got weekend plans?',
'Orgs that performed timely server upgrades averaged 19% revenue growth.',
'Seeing all of the @ mention mint scams of late…',
"For fuck's sake, Gary.",
'And another',
'Ah go on, one more',
"Wait, so there's no way to test microphone settings for Twitter Spaces without creating a Twitter Space, going 'live', and having it notify everyone? Amazing.",
'gidge day',
'British Defense Intelligence Ukraine Update, July 20, 2022\n' +
'#RussiaUkraineWar',
' Were launching a brand new template soon on @tailwindui Im really excited about this one! \n' +
'\n' +
'Here is a preview:',
'Are you looking for a pediatrician near your home or work? ARC has pediatricians in 23 locations around Central Texas. \n' +
'\n' +
'Our pediatricians are ready to partner with you in the growth and development of your child and family, no matter where you live.',
'And well be making undici-fetch happen. H/t @matteocollina',
"It's Wednesday, you know what that means....\n" +
'\n' +
' Office Hours - 9:30AM PDT / 12:30PM EDT / 4:30PM (16:30) GMT \n' +
'\n' +
' Maker Hour - 1:00PM PDT / 4:00PM EDT / 8:00PM (20:00) GMT\n' +
'\n' +
'Join us in @discord',
"Europe's bleak Russian winter, made stark: \n" +
'\n' +
"Brussels proposes Europe cuts its gas usage by 15 per cent between now and April next year if governments literally want to keep the lights on and people's homes warm.",
'Tips for using TypeScript via JSDoc comments',
'My talk is today come say hi!',
'Im giving a talk on private smart contract using homomorphic encryption on Wednesday at 2:20pm. Come say hi! #fhe #web3 #privacy #ethereum',
'We have just showcased the new build and discussed our future plans to support developers Recording coming soon! If you are in Paris drop us a line to grab a coffee ',
'TFW you forget to buy milk for your morning coffee and now have to get dressed and go outside at 6:30am.',
'seeing an awful lot of articles referring to the "European heatwave" in the past tense. first of all:',
'Is there a way to fix this issue this when using TypeScript via JSDoc comments? (Reply to the quoted tweet.)',
'For instance in this code I dont know how to tell TS that, no, the `res` will not be `string`. (`api` is an instance of the `got` npm package).',
'In the latest magazine, bob kindly invited me to write about techniques for ensuring convergence in distributed systems. Article is now up!',
"This is a reboot of the “Research for Practice” column that @alice.com used to run. The idea is to distill some key ideas from recent CS research and to make them accessible to folks in industry who don't normally have time for reading papers.",
"This is cool: post a video to @whimful's REVERSE CONFERENCE @ DWeb so you can maximize your human connecting time at camp ",
'New blog post: How to speed up the Rust compiler in July 2022\n' +
'\n' +
'https://nnethercote.github.io/2022/07/20/how-to-speed-up-the-rust-compiler-in-july-2022.html…\n' +
'\n' +
'PGO on Windows, MIR inlining, better derived code for builtin traits, and much more!',
'my new favorite past time is watching older couples at the airport \n' +
'\n' +
'Wife: “Put your mask on. I dont want you sick for your birthday.”\n' +
'Husband: “Can you leave me alone for one goddamn minute?”',
"We're all gonna die",
'Builders building, this month at @NativeHostels on 4th Street.\n' +
'- Coworking & coffee on Tuesdays 11am-3pm \n' +
'- HAPPY HOUR HANGOUTS on Thursdays 7:30-10pm \n' +
'RSVP: https://ATXDAO-Native-HH.eventbrite.com',
'crypto meetings IRL are my favorite.\n' +
'\n' +
'@ATXDAO\n' +
'@HopeCampaign\n' +
'@NativeHostels\n' +
'@wagmiyall \n' +
'@evrydayresearch \n' +
'@zcreativemedia \n' +
'@CrystalGravy2\n' +
'@NickCPlusPlus\n' +
'@_ElaineAlonzo',
'I recommend @Scroll_ZKP create and hire for a developer advocate position to deliver world class developer experience.',
'Intelligent Tracking Prevention identifies trackers and helps prevent them from profiling or following you across the web.',
"Big story in Norway this summer is a walrus we've named Freya has made it to our shores and is touring the country, laying around and sinking boats",
'"the other fellows at the CIA were unequivocally against assassinating three of the greatest musicians of their generation, but I went in there and held my convictions and said my piece."',
'hls is annoying technology',
'This summer, prey for survival. Experience #BeastMovie only in theaters August 19.',
"got the new twitter update. i don't like it",
'Might just set this as a daily reminder.',
'Gm. Today, ask yourself:\n' +
'\n' +
'Is my Web3/NFT/DeFi/crypto making a better life for someone other than myself?',
'every time I add a piece to my native #webgl engine I end up crying and appreciating even more the work @mrdoob and the @threejs team is doing. yall have no idea',
'i dont like having 2 plagues and no healthcare. i should have 0 plagues and 2 healthcare',
'this is the only lotr behind the scenes pic that matters actually',
'Photographer took pictures of a baby hippo scared of birds',
'lol',
"VC's explaining web3.",
'Cyberattacks cost banks $18.3 million annually per company. We ensure that youre not part of that statistic.#stayrelentless.',
'In SF, you can choose kind of weather you want',
'a day in earth 2.0 should be 26 hours',
'who wants to start a religion',
'We just launched our code-hosting platform!\n' +
'\n' +
"It's like Github, but has hyperlinked syntax-highlighted code and docs from day one \n" +
'\n' +
"I can't overemphasize how nice it feels to push code and immediately see it there, published, highlighted, and linked up!",
'Now that youve seen them do the universe, its time for them to do a new series. Mike Judges Beavis and Butt-Head is coming to #ParamountPlus on August 4th. #beavisandbutthead',
'Organized a nice crypto research dinner with @protocollabs and @zama_fhe - very vibrant community with a lot of open problems :)\n' +
'\n' +
'Planning to organize more going forward - reach out if this is your vibe!',
"Houston drivers are crazy. Been here two days and have had multiple vehicles charging up and tailing me, then switching lanes, roaring past, and weaving through traffic. While I'm going 75mph, keeping with the flow of traffic. WTF??",
`LIVE NOW: a mix of projects coming to #DWebCamp2022! @gvelez17 of @dsocialcommons is planning "Dogfooding Decentralization" a user-testing lab at DWeb Camp. You can sign up to present your tools. We'll send you the link tomorrow! 1/n`,
`Fabiola of @guardianproject is bringing "Butter Box" "Inspiration Beans" & more. (They must love ) Butter Box is raspberry pi-based hotspot w/ encrypted chat. It's opensource & offline first. Come onboard & connect with others offline. (8/n)`,
"Up Now: @guardianproject's Jack Fox Keen w/ tools to verify the authencity++ of data. They are leading a Cryptographically Verified Scavenger Hunt (9/n)",
'Translating VC Language',
'What would Sherlock Holmes do? Listen to true crime podcasts early on Amazon Music. #TrueCrimeonAmazonMusic',
'How to deal with a hung Linux system',
'ALRIGHT PELOTON HAPPY MONDAY I WANNA FEEL THAT ENERGY COMING THROUGH THE SCREEN AS WE START GETTING OUR AMAZING BODIES WARMED UP. THIS FIRST TRACK IS CALLED "DONALD DUCK GETTING A BLOWJOB" RESISTANCE 30 CADENCE 80',
"Thank you to all who joined us at @pbailis' executive roundtable lunch at @VentureBeat's #VBTransform!\n" +
'\n' +
'#ICYMI, stop by the Sisu table today to meet the team & learn how to supercharge your analytics workflow with decision intelligence.',
'Prescribing Info: http://bit.ly/2cTmItE?twclid=2-1jogj2v7zxu2rw1zmzyms8jpg \n' +
'Med Guide: http://bit.ly/2yon94P?twclid=2-1jogj2v7zxu2rw1zmzyms8jpg\n' +
'\n' +
'Discover a treatment for certain patients with kidney cancer (RCC).',
'Want to learn how to write E2EE P2P apps without even trying? Check out the new application sandbox and api in Peergos!',
'Ageism was always a reality in tech. \n' +
'\n' +
'The “in-office” only crowd have found a way to make their ageism more respectable.\n' +
'\n' +
'PS - most startups would rather hire the 32 yo Senior FAANG engineer than a 20 something grad who needs a ton of mentorship.',
"I don't mind jumping on a call at work. I think that should be the last resort, not the first one.",
'If you missed attending #MSInspire, we missed you too. You can still find plenty of on-demand content available at your convenience. Take advantage of the ongoing opportunity to enrich your business prospects.',
"TODAY AT 4 PM PT: Join for the DWeb Meetup where we'll see some of the workshops coming to DWeb Camp 2022:\n" +
'@metagov_project\n' +
' @ameliawb\n' +
' @guardianproject\n' +
' @gvelez17\n' +
' @infrared_ether\n' +
'@LauBenedict\n' +
' @guardianproject\n' +
'@byUnfinished\n' +
' Come be inspired:',
]
-24
View File
@@ -1,24 +0,0 @@
export const replyTexts = [
'Wow, so true!',
'Haha ikr',
'lol',
'What does this mean for democrats in the midterms?',
'What does this mean for republicans in the midterms?',
'What does this mean for pet owners in the midterms?',
'is it cool if I DM?',
"This is sort of accurate, but honestly it misses a huge part of the issue at hand. There are just so many factors and I don't think a vibe is enough to cover it.",
'Wen token',
'This is disgusting and you should take it down',
'fire',
'a/s/l?',
'this is so much better than twitter',
'this is so much better than tiktok',
'this is so much better than gab',
'this is so much better than truth social',
'finally! decentralization!',
'ugh when will hashtags get supported in vibe',
'ted cruz is the deep state!',
'aoc is the deep state!',
'donald trump is the deep state!',
'taco tuesdays is the deep state!',
]
+4
View File
@@ -8,3 +8,7 @@ export function hasProp<K extends PropertyKey>(
): data is Record<K, unknown> {
return prop in data
}
export function isStrArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(item => typeof item === 'string')
}
+368 -85
View File
@@ -1,117 +1,221 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {bsky} from '@adxp/mock-api'
import {Record as PostRecord} from '../../third-party/api/src/client/types/app/bsky/feed/post'
import * as GetTimeline from '../../third-party/api/src/client/types/app/bsky/feed/getTimeline'
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
import * as GetAuthorFeed from '../../third-party/api/src/client/types/app/bsky/feed/getAuthorFeed'
import {PostThreadViewModel} from './post-thread-view'
import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api'
import {cleanError} from '../../lib/strings'
import {isObj, hasProp} from '../lib/type-guards'
export class FeedViewItemMyStateModel {
hasLiked: boolean = false
hasReposted: boolean = false
const PAGE_SIZE = 30
type FeedItem = GetTimeline.FeedItem | GetAuthorFeed.FeedItem
type FeedItemWithThreadMeta = FeedItem & {
_isThreadParent?: boolean
_isThreadChild?: boolean
}
export class FeedItemMyStateModel {
repost?: string
upvote?: string
downvote?: string
constructor() {
makeAutoObservable(this)
}
}
export class FeedViewItemModel implements bsky.FeedView.FeedItem {
export class FeedItemModel implements GetTimeline.FeedItem {
// ui state
_reactKey: string = ''
_isThreadParent: boolean = false
_isThreadChild: boolean = false
// data
uri: string = ''
author: bsky.FeedView.User = {did: '', name: '', displayName: ''}
repostedBy?: bsky.FeedView.User
cid: string = ''
author: ActorRef.WithInfo = {
did: '',
handle: '',
displayName: '',
declaration: {cid: '', actorType: ''},
avatar: undefined,
}
repostedBy?: ActorRef.WithInfo
trendedBy?: ActorRef.WithInfo
record: Record<string, unknown> = {}
embed?:
| bsky.FeedView.RecordEmbed
| bsky.FeedView.ExternalEmbed
| bsky.FeedView.UnknownEmbed
replyCount: number = 0
repostCount: number = 0
likeCount: number = 0
upvoteCount: number = 0
downvoteCount: number = 0
indexedAt: string = ''
myState = new FeedViewItemMyStateModel()
myState = new FeedItemMyStateModel()
// additional data
additionalParentPost?: PostThreadViewModel
constructor(
public rootStore: RootStoreModel,
reactKey: string,
v: bsky.FeedView.FeedItem,
v: FeedItemWithThreadMeta,
) {
makeAutoObservable(this, {rootStore: false})
this._reactKey = reactKey
this.copy(v)
this._isThreadParent = v._isThreadParent || false
this._isThreadChild = v._isThreadChild || false
}
copy(v: bsky.FeedView.FeedItem) {
copy(v: GetTimeline.FeedItem | GetAuthorFeed.FeedItem) {
this.uri = v.uri
this.cid = v.cid
this.author = v.author
this.repostedBy = v.repostedBy
this.trendedBy = v.trendedBy
this.record = v.record
this.embed = v.embed
this.replyCount = v.replyCount
this.repostCount = v.repostCount
this.likeCount = v.likeCount
this.upvoteCount = v.upvoteCount
this.downvoteCount = v.downvoteCount
this.indexedAt = v.indexedAt
if (v.myState) {
this.myState.hasLiked = v.myState.hasLiked
this.myState.hasReposted = v.myState.hasReposted
this.myState.upvote = v.myState.upvote
this.myState.downvote = v.myState.downvote
this.myState.repost = v.myState.repost
}
}
async toggleLike() {
if (this.myState.hasLiked) {
await apilib.unlike(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.likeCount--
this.myState.hasLiked = false
})
} else {
await apilib.like(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.likeCount++
this.myState.hasLiked = true
})
}
async toggleUpvote() {
const wasUpvoted = !!this.myState.upvote
const wasDownvoted = !!this.myState.downvote
const res = await this.rootStore.api.app.bsky.feed.setVote({
subject: {
uri: this.uri,
cid: this.cid,
},
direction: wasUpvoted ? 'none' : 'up',
})
runInAction(() => {
if (wasDownvoted) {
this.downvoteCount--
}
if (wasUpvoted) {
this.upvoteCount--
} else {
this.upvoteCount++
}
this.myState.upvote = res.data.upvote
this.myState.downvote = res.data.downvote
})
}
async toggleDownvote() {
const wasUpvoted = !!this.myState.upvote
const wasDownvoted = !!this.myState.downvote
const res = await this.rootStore.api.app.bsky.feed.setVote({
subject: {
uri: this.uri,
cid: this.cid,
},
direction: wasDownvoted ? 'none' : 'down',
})
runInAction(() => {
if (wasUpvoted) {
this.upvoteCount--
}
if (wasDownvoted) {
this.downvoteCount--
} else {
this.downvoteCount++
}
this.myState.upvote = res.data.upvote
this.myState.downvote = res.data.downvote
})
}
async toggleRepost() {
if (this.myState.hasReposted) {
await apilib.unrepost(this.rootStore.api, 'alice.com', this.uri)
if (this.myState.repost) {
await apilib.unrepost(this.rootStore, this.myState.repost)
runInAction(() => {
this.repostCount--
this.myState.hasReposted = false
this.myState.repost = undefined
})
} else {
await apilib.repost(this.rootStore.api, 'alice.com', this.uri)
const res = await apilib.repost(this.rootStore, this.uri, this.cid)
runInAction(() => {
this.repostCount++
this.myState.hasReposted = true
this.myState.repost = res.uri
})
}
}
async delete() {
await this.rootStore.api.app.bsky.feed.post.delete({
did: this.author.did,
rkey: new AtUri(this.uri).rkey,
})
}
get needsAdditionalData() {
if (
(this.record as PostRecord).reply?.parent?.uri &&
!this._isThreadChild
) {
return !this.additionalParentPost
}
return false
}
async fetchAdditionalData() {
if (!this.needsAdditionalData) {
return
}
this.additionalParentPost = new PostThreadViewModel(this.rootStore, {
uri: (this.record as PostRecord).reply?.parent.uri,
depth: 0,
})
await this.additionalParentPost.setup().catch(e => {
console.error('Failed to load post needed by notification', e)
})
}
}
export class FeedViewModel implements bsky.FeedView.Response {
export class FeedModel {
// state
isLoading = false
isRefreshing = false
hasNewLatest = false
hasLoaded = false
error = ''
params: bsky.FeedView.Params
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
hasMore = true
loadMoreCursor: string | undefined
pollCursor: string | undefined
_loadPromise: Promise<void> | undefined
_loadMorePromise: Promise<void> | undefined
_loadLatestPromise: Promise<void> | undefined
_updatePromise: Promise<void> | undefined
// data
feed: FeedViewItemModel[] = []
feed: FeedItemModel[] = []
constructor(public rootStore: RootStoreModel, params: bsky.FeedView.Params) {
constructor(
public rootStore: RootStoreModel,
public feedType: 'home' | 'author',
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
loadMoreCursor: false,
_loadPromise: false,
_loadMorePromise: false,
_loadLatestPromise: false,
_updatePromise: false,
},
{autoBind: true},
@@ -131,11 +235,19 @@ export class FeedViewModel implements bsky.FeedView.Response {
return this.hasLoaded && !this.hasContent
}
get loadMoreCursor() {
if (this.hasContent) {
return this.feed[this.feed.length - 1].indexedAt
}
return undefined
get nonReplyFeed() {
return this.feed.filter(
post =>
!post.record.reply || // not a reply
!!post.repostedBy || // or a repost
!!post.trendedBy || // or a trend
post._isThreadParent || // but allow if it's a thread by the user
post._isThreadChild,
)
}
setHasNewLatest(v: boolean) {
this.hasNewLatest = v
}
// public api
@@ -145,10 +257,14 @@ export class FeedViewModel implements bsky.FeedView.Response {
* Load for first render
*/
async setup(isRefreshing = false) {
if (isRefreshing) {
this.isRefreshing = true // set optimistically for UI
}
if (this._loadPromise) {
return this._loadPromise
}
await this._pendingWork()
this.setHasNewLatest(false)
this._loadPromise = this._initialLoad(isRefreshing)
await this._loadPromise
this._loadPromise = undefined
@@ -174,6 +290,20 @@ export class FeedViewModel implements bsky.FeedView.Response {
this._loadMorePromise = undefined
}
/**
* Load more posts to the start of the feed
*/
async loadLatest() {
if (this._loadLatestPromise) {
return this._loadLatestPromise
}
await this._pendingWork()
this.setHasNewLatest(false)
this._loadLatestPromise = this._loadLatest()
await this._loadLatestPromise
this._loadLatestPromise = undefined
}
/**
* Update content in-place
*/
@@ -187,6 +317,21 @@ export class FeedViewModel implements bsky.FeedView.Response {
this._updatePromise = undefined
}
/**
* Check if new postrs are available
*/
async checkForLatest() {
if (this.hasNewLatest) {
return
}
await this._pendingWork()
const res = await this._getFeed({limit: 1})
this.setHasNewLatest(
res.data.feed[0] &&
(this.feed.length === 0 || res.data.feed[0].uri !== this.pollCursor),
)
}
// state transitions
// =
@@ -200,7 +345,7 @@ export class FeedViewModel implements bsky.FeedView.Response {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
this.error = cleanError(err)
}
// loader functions
@@ -213,6 +358,9 @@ export class FeedViewModel implements bsky.FeedView.Response {
if (this._loadMorePromise) {
await this._loadMorePromise
}
if (this._loadLatestPromise) {
await this._loadLatestPromise
}
if (this._updatePromise) {
await this._updatePromise
}
@@ -220,31 +368,37 @@ export class FeedViewModel implements bsky.FeedView.Response {
private async _initialLoad(isRefreshing = false) {
this._xLoading(isRefreshing)
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:FeedView',
this.params,
)) as bsky.FeedView.Response
this._replaceAll(res)
const res = await this._getFeed({limit: PAGE_SIZE})
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`)
this._xIdle(e.toString())
}
}
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.toString())
}
}
private async _loadMore() {
if (!this.hasMore) {
return
}
this._xLoading()
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const params = Object.assign({}, this.params, {
const res = await this._getFeed({
before: this.loadMoreCursor,
limit: PAGE_SIZE,
})
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:FeedView',
params,
)) as bsky.FeedView.Response
this._appendAll(res)
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`)
@@ -252,26 +406,25 @@ export class FeedViewModel implements bsky.FeedView.Response {
}
private async _update() {
if (!this.feed.length) {
return
}
this._xLoading()
await new Promise(r => setTimeout(r, 250)) // DEBUG
let numToFetch = this.feed.length
let cursor = undefined
try {
do {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:FeedView',
{
before: cursor,
limit: Math.min(numToFetch, 100),
},
)) as bsky.FeedView.Response
if (res.feed.length === 0) {
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.feed.length
cursor = this.feed[res.feed.length - 1].indexedAt
console.log(numToFetch, cursor, res.feed.length)
numToFetch -= res.data.feed.length
cursor = this.feed[res.data.feed.length - 1].indexedAt
console.log(numToFetch, cursor, res.data.feed.length)
} while (numToFetch > 0)
this._xIdle()
} catch (e: any) {
@@ -279,25 +432,93 @@ export class FeedViewModel implements bsky.FeedView.Response {
}
}
private _replaceAll(res: bsky.FeedView.Response) {
this.feed.length = 0
this._appendAll(res)
private async _replaceAll(
res: GetTimeline.Response | GetAuthorFeed.Response,
) {
this.pollCursor = res.data.feed[0]?.uri
return this._appendAll(res, true)
}
private _appendAll(res: bsky.FeedView.Response) {
private async _appendAll(
res: GetTimeline.Response | GetAuthorFeed.Response,
replace = false,
) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
let counter = this.feed.length
for (const item of res.feed) {
this._append(counter++, item)
// HACK 1
// rearrange the posts to represent threads
// (should be done on the server)
// -prf
// HACK 2
// deduplicate posts on the home feed
// (should be done on the server)
// -prf
const reorgedFeed = preprocessFeed(res.data.feed, this.feedType === 'home')
const promises = []
const toAppend: FeedItemModel[] = []
for (const item of reorgedFeed) {
const itemModel = new FeedItemModel(
this.rootStore,
`item-${counter++}`,
item,
)
if (itemModel.needsAdditionalData) {
promises.push(
itemModel.fetchAdditionalData().catch(e => {
console.error('Failure during feed-view _appendAll()', e)
}),
)
}
toAppend.push(itemModel)
}
await Promise.all(promises)
runInAction(() => {
if (replace) {
this.feed = toAppend
} else {
this.feed = this.feed.concat(toAppend)
}
})
}
private _append(keyId: number, item: bsky.FeedView.FeedItem) {
// TODO: validate .record
this.feed.push(new FeedViewItemModel(this.rootStore, `item-${keyId}`, item))
private async _prependAll(
res: GetTimeline.Response | GetAuthorFeed.Response,
) {
this.pollCursor = res.data.feed[0]?.uri
let counter = this.feed.length
const promises = []
const toPrepend: FeedItemModel[] = []
for (const item of res.data.feed) {
if (this.feed.find(item2 => item2.uri === item.uri)) {
break // stop here - we've hit a post we already have
}
const itemModel = new FeedItemModel(
this.rootStore,
`item-${counter++}`,
item,
)
if (itemModel.needsAdditionalData) {
promises.push(
itemModel.fetchAdditionalData().catch(e => {
console.error('Failure during feed-view _prependAll()', e)
}),
)
}
toPrepend.push(itemModel)
}
await Promise.all(promises)
runInAction(() => {
this.feed = toPrepend.concat(this.feed)
})
}
private _updateAll(res: bsky.FeedView.Response) {
for (const item of res.feed) {
private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
for (const item of res.data.feed) {
const existingItem = this.feed.find(
// this find function has a key subtley- the indexedAt comparison
// the reason for this is reposts: they set the URI of the original post, not of the repost record
@@ -309,4 +530,66 @@ export class FeedViewModel implements bsky.FeedView.Response {
}
}
}
protected _getFeed(
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
params = Object.assign({}, this.params, params)
if (this.feedType === 'home') {
return this.rootStore.api.app.bsky.feed.getTimeline(
params as GetTimeline.QueryParams,
)
} else {
return this.rootStore.api.app.bsky.feed.getAuthorFeed(
params as GetAuthorFeed.QueryParams,
)
}
}
}
function preprocessFeed(
feed: FeedItem[],
dedup: boolean,
): FeedItemWithThreadMeta[] {
const reorg: FeedItemWithThreadMeta[] = []
for (let i = feed.length - 1; i >= 0; i--) {
const item = feed[i] as FeedItemWithThreadMeta
if (dedup) {
if (reorg.find(item2 => item2.uri === item.uri)) {
continue
}
}
const selfReplyUri = getSelfReplyUri(item)
if (selfReplyUri) {
const parentIndex = reorg.findIndex(item2 => item2.uri === selfReplyUri)
if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) {
reorg[parentIndex]._isThreadParent = true
item._isThreadChild = true
reorg.splice(parentIndex + 1, 0, item)
continue
}
}
reorg.unshift(item)
}
return reorg
}
function getSelfReplyUri(
item: GetTimeline.FeedItem | GetAuthorFeed.FeedItem,
): string | undefined {
if (
isObj(item.record) &&
hasProp(item.record, 'reply') &&
isObj(item.record.reply) &&
hasProp(item.record.reply, 'parent') &&
isObj(item.record.reply.parent) &&
hasProp(item.record.reply.parent, 'uri') &&
typeof item.record.reply.parent.uri === 'string'
) {
if (new AtUri(item.record.reply.parent.uri).host === item.author.did) {
return item.record.reply.parent.uri
}
}
}
+121
View File
@@ -0,0 +1,121 @@
import {makeAutoObservable} from 'mobx'
import * as GetAssertions from '../../third-party/api/src/client/types/app/bsky/graph/getAssertions'
import {RootStoreModel} from './root-store'
type ResponseAssertion = GetAssertions.OutputSchema['assertions'][number]
export type Assertion = ResponseAssertion & {
_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: string = '') {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = 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.toString())
}
}
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)
}
}
+44
View File
@@ -0,0 +1,44 @@
import {makeAutoObservable} from 'mobx'
import {LRUMap} from 'lru_map'
import {RootStoreModel} from './root-store'
import {LinkMeta, getLinkMeta} from '../../lib/link-meta'
type CacheValue = Promise<LinkMeta> | LinkMeta
export class LinkMetasViewModel {
cache: LRUMap<string, CacheValue> = new LRUMap(100)
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
cache: false,
},
{autoBind: true},
)
}
// public api
// =
async getLinkMeta(url: string) {
const cached = this.cache.get(url)
if (cached) {
try {
return await cached
} catch (e) {
// ignore, we'll try again
}
}
try {
const promise = getLinkMeta(url)
this.cache.set(url, promise)
const res = await promise
this.cache.set(url, res)
return res
} catch (e) {
this.cache.delete(url)
throw e
}
}
}
+106 -21
View File
@@ -1,41 +1,126 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from './root-store'
import {MembershipsViewModel} from './memberships-view'
import {NotificationsViewModel} from './notifications-view'
import {isObj, hasProp} from '../lib/type-guards'
export class MeModel {
did?: string
name?: string
displayName?: string
description?: string
did: string = ''
handle: string = ''
displayName: string = ''
description: string = ''
avatar: string = ''
notificationCount: number = 0
memberships?: MembershipsViewModel
notifications: NotificationsViewModel
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {rootStore: false}, {autoBind: true})
makeAutoObservable(
this,
{rootStore: false, serialize: false, hydrate: false},
{autoBind: true},
)
this.notifications = new NotificationsViewModel(this.rootStore, {})
}
clear() {
this.did = ''
this.handle = ''
this.displayName = ''
this.description = ''
this.avatar = ''
this.notificationCount = 0
this.memberships = undefined
}
serialize(): unknown {
return {
did: this.did,
handle: this.handle,
displayName: this.displayName,
description: this.description,
avatar: this.avatar,
}
}
hydrate(v: unknown) {
if (isObj(v)) {
let did, handle, displayName, description, avatar
if (hasProp(v, 'did') && typeof v.did === 'string') {
did = v.did
}
if (hasProp(v, 'handle') && typeof v.handle === 'string') {
handle = v.handle
}
if (hasProp(v, 'displayName') && typeof v.displayName === 'string') {
displayName = v.displayName
}
if (hasProp(v, 'description') && typeof v.description === 'string') {
description = v.description
}
if (hasProp(v, 'avatar') && typeof v.avatar === 'string') {
avatar = v.avatar
}
if (did && handle) {
this.did = did
this.handle = handle
this.displayName = displayName || ''
this.description = description || ''
this.avatar = avatar || ''
}
}
}
async load() {
const sess = this.rootStore.session
if (sess.isAuthed) {
const userDb = this.rootStore.api.mockDb.mainUser
this.did = userDb.did
this.name = userDb.name
const profile = await this.rootStore.api
.repo(this.did, true)
.collection('blueskyweb.xyz:Profiles')
.get('Profile', 'profile')
.catch(_ => undefined)
if (sess.hasSession && sess.data) {
this.did = sess.data.did || ''
this.handle = sess.data.handle
const profile = await this.rootStore.api.app.bsky.actor.getProfile({
actor: this.did,
})
runInAction(() => {
if (profile?.valid) {
this.displayName = profile.value.displayName
this.description = profile.value.description
if (profile?.data) {
this.displayName = profile.data.displayName || ''
this.description = profile.data.description || ''
this.avatar = profile.data.avatar || ''
} else {
this.displayName = ''
this.description = ''
this.avatar = ''
}
})
this.memberships = new MembershipsViewModel(this.rootStore, {
actor: this.did,
})
await this.memberships?.setup().catch(e => {
console.error('Failed to setup memberships model', e)
})
await this.notifications.setup().catch(e => {
console.error('Failed to setup notifications model', e)
})
} else {
this.did = undefined
this.name = undefined
this.displayName = undefined
this.description = undefined
this.clear()
}
}
clearNotificationCount() {
this.notificationCount = 0
}
async fetchStateUpdate() {
const res = await this.rootStore.api.app.bsky.notification.getCount()
runInAction(() => {
const newNotifications = this.notificationCount !== res.data.count
this.notificationCount = res.data.count
if (newNotifications) {
// trigger pre-emptive fetch on new notifications
this.notifications.refresh()
}
})
}
async refreshMemberships() {
return this.memberships?.refresh()
}
}
+144
View File
@@ -0,0 +1,144 @@
import {makeAutoObservable, runInAction} from 'mobx'
import * as GetMembers from '../../third-party/api/src/client/types/app/bsky/graph/getMembers'
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
import {APP_BSKY_GRAPH} from '../../third-party/api'
import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from './root-store'
export type MemberItem = GetMembers.Member & {
_reactKey: string
}
export class MembersViewModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: GetMembers.QueryParams
// data
subject: ActorRef.WithInfo = {
did: '',
handle: '',
displayName: '',
declaration: {cid: '', actorType: ''},
avatar: undefined,
}
members: MemberItem[] = []
constructor(
public rootStore: RootStoreModel,
params: GetMembers.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.members.length !== 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
isMember(did: string) {
return this.members.find(member => member.did === did)
}
// public api
// =
async setup() {
await this._fetch()
}
async refresh() {
await this._fetch(true)
}
async loadMore() {
// TODO
}
async removeMember(did: string) {
const assertsRes = await this.rootStore.api.app.bsky.graph.getAssertions({
author: this.subject.did,
subject: did,
assertion: APP_BSKY_GRAPH.AssertMember,
})
if (assertsRes.data.assertions.length < 1) {
throw new Error('Could not find membership record')
}
for (const assert of assertsRes.data.assertions) {
await this.rootStore.api.app.bsky.graph.assertion.delete({
did: this.subject.did,
rkey: new AtUri(assert.uri).rkey,
})
}
runInAction(() => {
this.members = this.members.filter(m => m.did !== did)
})
}
// state transitions
// =
private _xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
private _xIdle(err: string = '') {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
}
// loader functions
// =
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const res = await this.rootStore.api.app.bsky.graph.getMembers(
this.params,
)
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`)
}
}
private _replaceAll(res: GetMembers.Response) {
this.subject.did = res.data.subject.did
this.subject.handle = res.data.subject.handle
this.subject.displayName = res.data.subject.displayName
this.subject.declaration = res.data.subject.declaration
this.subject.avatar = res.data.subject.avatar
this.members.length = 0
let counter = 0
for (const item of res.data.members) {
this._append({_reactKey: `item-${counter++}`, ...item})
}
}
private _append(item: MemberItem) {
this.members.push(item)
}
}
+122
View File
@@ -0,0 +1,122 @@
import {makeAutoObservable} from 'mobx'
import * as GetMemberships from '../../third-party/api/src/client/types/app/bsky/graph/getMemberships'
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
import {RootStoreModel} from './root-store'
export type MembershipItem = GetMemberships.Membership & {
_reactKey: string
}
export class MembershipsViewModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: GetMemberships.QueryParams
// data
subject: ActorRef.WithInfo = {
did: '',
handle: '',
displayName: '',
declaration: {cid: '', actorType: ''},
avatar: undefined,
}
memberships: MembershipItem[] = []
constructor(
public rootStore: RootStoreModel,
params: GetMemberships.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.memberships.length !== 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
isMemberOf(did: string) {
return !!this.memberships.find(m => m.did === did)
}
// 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: string = '') {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
}
// loader functions
// =
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const res = await this.rootStore.api.app.bsky.graph.getMemberships(
this.params,
)
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`)
}
}
private _replaceAll(res: GetMemberships.Response) {
this.subject.did = res.data.subject.did
this.subject.handle = res.data.subject.handle
this.subject.displayName = res.data.subject.displayName
this.subject.declaration = res.data.subject.declaration
this.subject.avatar = res.data.subject.avatar
this.memberships.length = 0
let counter = 0
for (const item of res.data.memberships) {
this._append({_reactKey: `item-${counter++}`, ...item})
}
}
private _append(item: MembershipItem) {
this.memberships.push(item)
}
}
+359
View File
@@ -0,0 +1,359 @@
import {makeAutoObservable} from 'mobx'
import {TABS_ENABLED} from '../../build-flags'
let __id = 0
function genId() {
return ++__id
}
// NOTE
// this model was originally built for a freeform "tabs" concept like a browser
// 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
// 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,
}
interface HistoryItem {
url: string
ts: number
title?: string
id: number
}
export type HistoryPtr = [number, number]
export class NavigationTabModel {
id = genId()
history: HistoryItem[]
index = 0
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()}]
}
makeAutoObservable(this, {
serialize: false,
hydrate: false,
})
}
// accessors
// =
get current() {
return this.history[this.index]
}
get canGoBack() {
return this.index > 0
}
get canGoForward() {
return this.index < this.history.length - 1
}
getBackList(n: number) {
const start = Math.max(this.index - n, 0)
const end = this.index
return this.history.slice(start, end).map((item, i) => ({
url: item.url,
title: item.title,
index: start + i,
id: item.id,
}))
}
get backTen() {
return this.getBackList(10)
}
getForwardList(n: number) {
const start = Math.min(this.index + 1, this.history.length)
const end = Math.min(this.index + n + 1, this.history.length)
return this.history.slice(start, end).map((item, i) => ({
url: item.url,
title: item.title,
index: start + i,
id: item.id,
}))
}
get forwardTen() {
return this.getForwardList(10)
}
// navigation
// =
navigate(url: string, title?: string) {
if (this.current?.url === url) {
this.refresh()
} else {
if (this.index < this.history.length - 1) {
this.history.length = this.index + 1
}
// TEMP ensure the tab has its purpose's main view -prf
if (this.history.length < 1) {
const fixedUrl =
this.fixedTabPurpose === TabPurpose.Notifs ? '/notifications' : '/'
this.history.push({url: fixedUrl, ts: Date.now(), id: genId()})
}
this.history.push({url, title, ts: Date.now(), id: genId()})
this.index = this.history.length - 1
}
}
refresh() {
this.history = [
...this.history.slice(0, this.index),
{
url: this.current.url,
title: this.current.title,
ts: Date.now(),
id: this.current.id,
},
...this.history.slice(this.index + 1),
]
}
goBack() {
if (this.canGoBack) {
this.index--
}
}
// TEMP
// a helper to bring the tab back to its base state
// -prf
fixedTabReset() {
this.index = 0
}
goForward() {
if (this.canGoForward) {
this.index++
}
}
goToIndex(index: number) {
if (index >= 0 && index <= this.history.length - 1) {
this.index = index
}
}
setTitle(id: number, title: string) {
this.history = this.history.map(h => {
if (h.id === id) {
return {...h, title}
}
return h
})
}
setIsNewTab(v: boolean) {
this.isNewTab = v
}
// persistence
// =
serialize(): unknown {
return {
history: this.history,
index: this.index,
}
}
hydrate(v: unknown) {
// TODO fixme
// if (isObj(v)) {
// if (hasProp(v, 'history') && Array.isArray(v.history)) {
// for (const item of v.history) {
// if (
// isObj(item) &&
// hasProp(item, 'url') &&
// typeof item.url === 'string'
// ) {
// let copy: HistoryItem = {
// url: item.url,
// ts:
// hasProp(item, 'ts') && typeof item.ts === 'number'
// ? item.ts
// : Date.now(),
// }
// if (hasProp(item, 'title') && typeof item.title === 'string') {
// copy.title = item.title
// }
// this.history.push(copy)
// }
// }
// }
// if (hasProp(v, 'index') && typeof v.index === 'number') {
// this.index = v.index
// }
// if (this.index >= this.history.length - 1) {
// this.index = this.history.length - 1
// }
// }
}
}
export class NavigationModel {
tabs: NavigationTabModel[] = [
new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Notifs),
]
tabIndex = 0
constructor() {
makeAutoObservable(this, {
serialize: false,
hydrate: false,
})
}
clear() {
this.tabs = [
new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Notifs),
]
this.tabIndex = 0
}
// accessors
// =
get tab() {
return this.tabs[this.tabIndex]
}
get tabCount() {
return this.tabs.length
}
isCurrentScreen(tabId: number, index: number) {
return this.tab.id === tabId && this.tab.index === index
}
// navigation
// =
navigate(url: string, title?: string) {
this.tab.navigate(url, title)
}
refresh() {
this.tab.refresh()
}
setTitle(ptr: HistoryPtr, title: string) {
this.tabs.find(t => t.id === ptr[0])?.setTitle(ptr[1], title)
}
handleLink(url: string) {
let path
if (url.startsWith('/')) {
path = url
} else if (url.startsWith('http')) {
try {
path = new URL(url).pathname
} catch (e) {
console.error('Invalid url', url, e)
return
}
} else {
console.error('Invalid url', url)
return
}
this.navigate(path)
}
// tab management
// =
// TEMP
// fixed tab helper function
// -prf
switchTo(purpose: TabPurpose, reset: boolean) {
if (purpose === TabPurpose.Notifs) {
this.tabIndex = 1
} else {
this.tabIndex = 0
}
if (reset) {
this.tab.fixedTabReset()
}
}
newTab(url: string, title?: string) {
if (!TABS_ENABLED) {
return this.navigate(url)
}
const tab = new NavigationTabModel(TabPurpose.Default)
tab.navigate(url, title)
tab.isNewTab = true
this.tabs.push(tab)
this.tabIndex = this.tabs.length - 1
}
setActiveTab(tabIndex: number) {
if (!TABS_ENABLED) {
return
}
this.tabIndex = Math.max(Math.min(tabIndex, this.tabs.length - 1), 0)
}
closeTab(tabIndex: number) {
if (!TABS_ENABLED) {
return
}
this.tabs = [
...this.tabs.slice(0, tabIndex),
...this.tabs.slice(tabIndex + 1),
]
if (this.tabs.length === 0) {
this.newTab('/')
} else if (this.tabIndex >= this.tabs.length) {
this.tabIndex = this.tabs.length - 1
}
}
// persistence
// =
serialize(): unknown {
return {
tabs: this.tabs.map(t => t.serialize()),
tabIndex: this.tabIndex,
}
}
hydrate(v: unknown) {
// TODO fixme
this.clear()
/*if (isObj(v)) {
if (hasProp(v, 'tabs') && Array.isArray(v.tabs)) {
for (const tab of v.tabs) {
const copy = new NavigationTabModel()
copy.hydrate(tab)
if (copy.history.length) {
this.tabs.push(copy)
}
}
}
if (hasProp(v, 'tabIndex') && typeof v.tabIndex === 'number') {
this.tabIndex = v.tabIndex
}
}*/
}
}
+207 -76
View File
@@ -1,72 +1,124 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {bsky} from '@adxp/mock-api'
import * as ListNotifications from '../../third-party/api/src/client/types/app/bsky/notification/list'
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
import {RootStoreModel} from './root-store'
import {PostThreadViewModel} from './post-thread-view'
import {hasProp} from '../lib/type-guards'
import {APP_BSKY_GRAPH} from '../../third-party/api'
import {cleanError} from '../../lib/strings'
export class NotificationsViewItemModel
implements bsky.NotificationsView.Notification
{
const UNGROUPABLE_REASONS = ['trend', 'assertion']
const PAGE_SIZE = 30
const MS_60MIN = 1e3 * 60 * 60
export interface GroupedNotification extends ListNotifications.Notification {
additional?: ListNotifications.Notification[]
}
export class NotificationsViewItemModel implements GroupedNotification {
// ui state
_reactKey: string = ''
// data
uri: string = ''
author: {
did: string
name: string
displayName: string
} = {did: '', name: '', displayName: ''}
cid: string = ''
author: ActorRef.WithInfo = {
did: '',
handle: '',
declaration: {cid: '', actorType: ''},
}
reason: string = ''
reasonSubject?: string
record: any = {}
isRead: boolean = false
indexedAt: string = ''
additional?: NotificationsViewItemModel[]
// additional data
additionalPost?: PostThreadViewModel
constructor(
public rootStore: RootStoreModel,
reactKey: string,
v: bsky.NotificationsView.Notification,
v: GroupedNotification,
) {
makeAutoObservable(this, {rootStore: false})
this._reactKey = reactKey
this.copy(v)
}
copy(v: bsky.NotificationsView.Notification) {
copy(v: GroupedNotification, preserve = false) {
this.uri = v.uri
this.cid = v.cid
this.author = v.author
this.reason = v.reason
this.reasonSubject = v.reasonSubject
this.record = v.record
this.isRead = v.isRead
this.indexedAt = v.indexedAt
if (v.additional?.length) {
this.additional = []
for (const add of v.additional) {
this.additional.push(
new NotificationsViewItemModel(this.rootStore, '', add),
)
}
} else if (!preserve) {
this.additional = undefined
}
}
get isLike() {
return (
hasProp(this.record, '$type') &&
this.record.$type === 'blueskyweb.xyz:Like'
)
get isUpvote() {
return this.reason === 'vote'
}
get isRepost() {
return (
hasProp(this.record, '$type') &&
this.record.$type === 'blueskyweb.xyz:Repost'
)
return this.reason === 'repost'
}
get isTrend() {
return this.reason === 'trend'
}
get isMention() {
return this.reason === 'mention'
}
get isReply() {
return (
hasProp(this.record, '$type') &&
this.record.$type === 'blueskyweb.xyz:Post'
)
return this.reason === 'reply'
}
get isFollow() {
return this.reason === 'follow'
}
get isAssertion() {
return this.reason === 'assertion'
}
get needsAdditionalData() {
if (
this.isUpvote ||
this.isRepost ||
this.isTrend ||
this.isReply ||
this.isMention
) {
return !this.additionalPost
}
return false
}
get isInvite() {
return (
hasProp(this.record, '$type') &&
this.record.$type === 'blueskyweb.xyz:Follow'
this.isAssertion && this.record.assertion === APP_BSKY_GRAPH.AssertMember
)
}
get subjectUri() {
if (this.reasonSubject) {
return this.reasonSubject
}
if (
hasProp(this.record, 'subject') &&
typeof this.record.subject === 'string'
@@ -75,15 +127,38 @@ export class NotificationsViewItemModel
}
return ''
}
async fetchAdditionalData() {
if (!this.needsAdditionalData) {
return
}
let postUri
if (this.isReply || this.isMention) {
postUri = this.uri
} else if (this.isUpvote || this.isRead || this.isTrend) {
postUri = this.subjectUri
}
if (postUri) {
this.additionalPost = new PostThreadViewModel(this.rootStore, {
uri: postUri,
depth: 0,
})
await this.additionalPost.setup().catch(e => {
console.error('Failed to load post needed by notification', e)
})
}
}
}
export class NotificationsViewModel implements bsky.NotificationsView.Response {
export class NotificationsViewModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: bsky.NotificationsView.Params
params: ListNotifications.QueryParams
hasMore = true
loadMoreCursor?: string
_loadPromise: Promise<void> | undefined
_loadMorePromise: Promise<void> | undefined
_updatePromise: Promise<void> | undefined
@@ -93,7 +168,7 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
constructor(
public rootStore: RootStoreModel,
params: bsky.NotificationsView.Params,
params: ListNotifications.QueryParams,
) {
makeAutoObservable(
this,
@@ -121,13 +196,6 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
return this.hasLoaded && !this.hasContent
}
get loadMoreCursor() {
if (this.hasContent) {
return this.notifications[this.notifications.length - 1].indexedAt
}
return undefined
}
// public api
// =
@@ -135,6 +203,9 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
* Load for first render
*/
async setup(isRefreshing = false) {
if (isRefreshing) {
this.isRefreshing = true // set optimistically for UI
}
if (this._loadPromise) {
return this._loadPromise
}
@@ -177,6 +248,20 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
this._updatePromise = undefined
}
/**
* Update read/unread state
*/
async updateReadState() {
try {
await this.rootStore.api.app.bsky.notification.updateSeen({
seenAt: new Date().toISOString(),
})
this.rootStore.me.clearNotificationCount()
} catch (e) {
console.log('Failed to update notifications read state', e)
}
}
// state transitions
// =
@@ -190,7 +275,7 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
this.error = cleanError(err)
}
// loader functions
@@ -210,13 +295,12 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
private async _initialLoad(isRefreshing = false) {
this._xLoading(isRefreshing)
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:NotificationsView',
this.params,
)) as bsky.NotificationsView.Response
this._replaceAll(res)
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(`Failed to load notifications: ${e.toString()}`)
@@ -224,17 +308,17 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
}
private async _loadMore() {
if (!this.hasMore) {
return
}
this._xLoading()
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
before: this.loadMoreCursor,
})
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:NotificationsView',
params,
)) as bsky.NotificationsView.Response
this._appendAll(res)
const res = await this.rootStore.api.app.bsky.notification.list(params)
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load notifications: ${e.toString()}`)
@@ -242,26 +326,26 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
}
private async _update() {
if (!this.notifications.length) {
return
}
this._xLoading()
await new Promise(r => setTimeout(r, 250)) // DEBUG
let numToFetch = this.notifications.length
let cursor = undefined
try {
do {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:NotificationsView',
{
const res: ListNotifications.Response =
await this.rootStore.api.app.bsky.notification.list({
before: cursor,
limit: Math.min(numToFetch, 100),
},
)) as bsky.NotificationsView.Response
if (res.notifications.length === 0) {
})
if (res.data.notifications.length === 0) {
break // sanity check
}
this._updateAll(res)
numToFetch -= res.notifications.length
cursor = this.notifications[res.notifications.length - 1].indexedAt
console.log(numToFetch, cursor, res.notifications.length)
numToFetch -= res.data.notifications.length
cursor = this.notifications[res.data.notifications.length - 1].indexedAt
console.log(numToFetch, cursor, res.data.notifications.length)
} while (numToFetch > 0)
this._xIdle()
} catch (e: any) {
@@ -269,36 +353,83 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
}
}
private _replaceAll(res: bsky.NotificationsView.Response) {
this.notifications.length = 0
this._appendAll(res)
private async _replaceAll(res: ListNotifications.Response) {
return this._appendAll(res, true)
}
private _appendAll(res: bsky.NotificationsView.Response) {
private async _appendAll(res: ListNotifications.Response, replace = false) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
let counter = this.notifications.length
for (const item of res.notifications) {
this._append(counter++, item)
const promises = []
const itemModels: NotificationsViewItemModel[] = []
for (const item of groupNotifications(res.data.notifications)) {
const itemModel = new NotificationsViewItemModel(
this.rootStore,
`item-${counter++}`,
item,
)
if (itemModel.needsAdditionalData) {
promises.push(itemModel.fetchAdditionalData())
}
itemModels.push(itemModel)
}
await Promise.all(promises).catch(e => {
console.error(
'Uncaught failure during notifications-view _appendAll()',
e,
)
})
runInAction(() => {
if (replace) {
this.notifications = itemModels
} else {
this.notifications = this.notifications.concat(itemModels)
}
})
}
private _append(keyId: number, item: bsky.NotificationsView.Notification) {
// TODO: validate .record
this.notifications.push(
new NotificationsViewItemModel(this.rootStore, `item-${keyId}`, item),
)
}
private _updateAll(res: bsky.NotificationsView.Response) {
for (const item of res.notifications) {
private _updateAll(res: ListNotifications.Response) {
for (const item of res.data.notifications) {
const existingItem = this.notifications.find(
// this find function has a key subtley- the indexedAt comparison
// 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,
)
if (existingItem) {
existingItem.copy(item)
existingItem.copy(item, true)
}
}
}
}
function groupNotifications(
items: ListNotifications.Notification[],
): GroupedNotification[] {
const items2: GroupedNotification[] = []
for (const item of items) {
const ts = +new Date(item.indexedAt)
let grouped = false
if (!UNGROUPABLE_REASONS.includes(item.reason)) {
for (const item2 of items2) {
const ts2 = +new Date(item2.indexedAt)
if (
Math.abs(ts2 - ts) < MS_60MIN &&
item.reason === item2.reason &&
item.reasonSubject === item2.reasonSubject &&
item.author.did !== item2.author.did
) {
item2.additional = item2.additional || []
item2.additional.push(item)
grouped = true
break
}
}
}
if (!grouped) {
items2.push(item)
}
}
return items2
}
+63
View File
@@ -0,0 +1,63 @@
import {makeAutoObservable} from 'mobx'
import {isObj, hasProp} from '../lib/type-guards'
export const OnboardStage = {
Explainers: 'explainers',
Follows: 'follows',
}
export const OnboardStageOrder = [OnboardStage.Explainers, OnboardStage.Follows]
export class OnboardModel {
isOnboarding: boolean = false
stage: string = OnboardStageOrder[0]
constructor() {
makeAutoObservable(this, {
serialize: false,
hydrate: false,
})
}
serialize(): unknown {
return {
isOnboarding: this.isOnboarding,
stage: this.stage,
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'isOnboarding') && typeof v.isOnboarding === 'boolean') {
this.isOnboarding = v.isOnboarding
}
if (
hasProp(v, 'stage') &&
typeof v.stage === 'string' &&
OnboardStageOrder.includes(v.stage)
) {
this.stage = v.stage
}
}
}
start() {
this.isOnboarding = true
}
stop() {
this.isOnboarding = false
}
next() {
if (!this.isOnboarding) return
let i = OnboardStageOrder.indexOf(this.stage)
i++
if (i >= OnboardStageOrder.length) {
this.isOnboarding = false
this.stage = OnboardStageOrder[0] // in case they make a new account
} else {
this.stage = OnboardStageOrder[i]
}
}
}
+178 -50
View File
@@ -1,9 +1,20 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {bsky, AdxUri} from '@adxp/mock-api'
import {AppBskyFeedGetPostThread as GetPostThread} from '../../third-party/api'
import * as Embed from '../../third-party/api/src/client/types/app/bsky/feed/embed'
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
import {AtUri} from '../../third-party/uri'
import _omit from 'lodash.omit'
import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api'
type MaybePost =
| GetPostThread.Post
| GetPostThread.NotFoundPost
| {
$type: string
[k: string]: unknown
}
function* reactKeyGenerator(): Generator<string> {
let counter = 0
while (true) {
@@ -11,41 +22,61 @@ function* reactKeyGenerator(): Generator<string> {
}
}
interface ReplyingTo {
author: {
handle: string
displayName?: string
avatar?: string
}
text: string
}
interface OriginalRecord {
text: string
}
export class PostThreadViewPostMyStateModel {
hasLiked: boolean = false
hasReposted: boolean = false
repost?: string
upvote?: string
downvote?: string
constructor() {
makeAutoObservable(this)
}
}
export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
export class PostThreadViewPostModel implements GetPostThread.Post {
// ui state
_reactKey: string = ''
_depth = 0
_isHighlightedPost = false
// data
$type: string = ''
uri: string = ''
author: bsky.PostThreadView.User = {did: '', name: '', displayName: ''}
cid: string = ''
author: ActorRef.WithInfo = {
did: '',
handle: '',
declaration: {cid: '', actorType: ''},
}
record: Record<string, unknown> = {}
embed?:
| bsky.PostThreadView.RecordEmbed
| bsky.PostThreadView.ExternalEmbed
| bsky.PostThreadView.UnknownEmbed
embed?: Embed.Main = undefined
parent?: PostThreadViewPostModel
replyCount: number = 0
replies?: PostThreadViewPostModel[]
repostCount: number = 0
likeCount: number = 0
upvoteCount: number = 0
downvoteCount: number = 0
indexedAt: string = ''
myState = new PostThreadViewPostMyStateModel()
// added data
replyingTo?: ReplyingTo
constructor(
public rootStore: RootStoreModel,
reactKey: string,
v?: bsky.PostThreadView.Post,
v?: GetPostThread.Post,
) {
makeAutoObservable(this, {rootStore: false})
this._reactKey = reactKey
@@ -57,9 +88,15 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
}
}
assignTreeModels(keyGen: Generator<string>, v: bsky.PostThreadView.Post) {
assignTreeModels(
keyGen: Generator<string>,
v: GetPostThread.Post,
includeParent = true,
includeChildren = true,
isFirstChild = true,
) {
// parents
if (v.parent) {
if (includeParent && v.parent) {
// TODO: validate .record
const parentModel = new PostThreadViewPostModel(
this.rootStore,
@@ -68,13 +105,24 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
)
parentModel._depth = this._depth - 1
if (v.parent.parent) {
parentModel.assignTreeModels(keyGen, v.parent)
parentModel.assignTreeModels(keyGen, v.parent, true, false)
}
this.parent = parentModel
}
if (!includeParent && v.parent?.author.handle && !isFirstChild) {
this.replyingTo = {
author: {
handle: v.parent.author.handle,
displayName: v.parent.author.displayName,
avatar: v.parent.author.avatar,
},
text: (v.parent.record as OriginalRecord).text,
}
}
// replies
if (v.replies) {
if (includeChildren && v.replies) {
const replies = []
let isChildFirstChild = true
for (const item of v.replies) {
// TODO: validate .record
const itemModel = new PostThreadViewPostModel(
@@ -84,44 +132,90 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
)
itemModel._depth = this._depth + 1
if (item.replies) {
itemModel.assignTreeModels(keyGen, item)
itemModel.assignTreeModels(
keyGen,
item,
false,
true,
isChildFirstChild,
)
}
isChildFirstChild = false
replies.push(itemModel)
}
this.replies = replies
}
}
async toggleLike() {
if (this.myState.hasLiked) {
await apilib.unlike(this.rootStore.api, 'alice.com', this.uri)
async toggleUpvote() {
const wasUpvoted = !!this.myState.upvote
const wasDownvoted = !!this.myState.downvote
const res = await this.rootStore.api.app.bsky.feed.setVote({
subject: {
uri: this.uri,
cid: this.cid,
},
direction: wasUpvoted ? 'none' : 'up',
})
runInAction(() => {
if (wasDownvoted) {
this.downvoteCount--
}
if (wasUpvoted) {
this.upvoteCount--
} else {
this.upvoteCount++
}
this.myState.upvote = res.data.upvote
this.myState.downvote = res.data.downvote
})
}
async toggleDownvote() {
const wasUpvoted = !!this.myState.upvote
const wasDownvoted = !!this.myState.downvote
const res = await this.rootStore.api.app.bsky.feed.setVote({
subject: {
uri: this.uri,
cid: this.cid,
},
direction: wasDownvoted ? 'none' : 'down',
})
runInAction(() => {
if (wasUpvoted) {
this.upvoteCount--
}
if (wasDownvoted) {
this.downvoteCount--
} else {
this.downvoteCount++
}
this.myState.upvote = res.data.upvote
this.myState.downvote = res.data.downvote
})
}
async toggleRepost() {
if (this.myState.repost) {
await apilib.unrepost(this.rootStore, this.myState.repost)
runInAction(() => {
this.likeCount--
this.myState.hasLiked = false
this.repostCount--
this.myState.repost = undefined
})
} else {
await apilib.like(this.rootStore.api, 'alice.com', this.uri)
const res = await apilib.repost(this.rootStore, this.uri, this.cid)
runInAction(() => {
this.likeCount++
this.myState.hasLiked = true
this.repostCount++
this.myState.repost = res.uri
})
}
}
async toggleRepost() {
if (this.myState.hasReposted) {
await apilib.unrepost(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.repostCount--
this.myState.hasReposted = false
})
} else {
await apilib.repost(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.repostCount++
this.myState.hasReposted = true
})
}
async delete() {
await this.rootStore.api.app.bsky.feed.post.delete({
did: this.author.did,
rkey: new AtUri(this.uri).rkey,
})
}
}
@@ -131,15 +225,16 @@ export class PostThreadViewModel {
isRefreshing = false
hasLoaded = false
error = ''
notFound = false
resolvedUri = ''
params: bsky.PostThreadView.Params
params: GetPostThread.QueryParams
// data
thread?: PostThreadViewPostModel
constructor(
public rootStore: RootStoreModel,
params: bsky.PostThreadView.Params,
params: GetPostThread.QueryParams,
) {
makeAutoObservable(
this,
@@ -201,20 +296,22 @@ export class PostThreadViewModel {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
this.notFound = false
}
private _xIdle(err: string = '') {
private _xIdle(err: any = undefined) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
this.error = err ? err.toString() : ''
this.notFound = err instanceof GetPostThread.NotFoundError
}
// loader functions
// =
private async _resolveUri() {
const urip = new AdxUri(this.params.uri)
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
urip.host = await this.rootStore.resolveName(urip.host)
}
@@ -226,27 +323,58 @@ export class PostThreadViewModel {
private async _load(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:PostThreadView',
const res = await this.rootStore.api.app.bsky.feed.getPostThread(
Object.assign({}, this.params, {uri: this.resolvedUri}),
)) as bsky.PostThreadView.Response
)
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load thread: ${e.toString()}`)
this._xIdle(e)
}
}
private _replaceAll(res: bsky.PostThreadView.Response) {
private _replaceAll(res: GetPostThread.Response) {
// TODO: validate .record
sortThread(res.data.thread)
const keyGen = reactKeyGenerator()
const thread = new PostThreadViewPostModel(
this.rootStore,
keyGen.next().value,
res.thread,
res.data.thread as GetPostThread.Post,
)
thread._isHighlightedPost = true
thread.assignTreeModels(keyGen, res.thread)
thread.assignTreeModels(keyGen, res.data.thread as GetPostThread.Post)
this.thread = thread
}
}
function sortThread(post: MaybePost) {
if (post.notFound) {
return
}
post = post as GetPostThread.Post
if (post.replies) {
post.replies.sort((a: MaybePost, b: MaybePost) => {
post = post as GetPostThread.Post
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
if (aIsByOp && bIsByOp) {
return a.indexedAt.localeCompare(b.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
})
post.replies.forEach(reply => sortThread(reply))
}
}
+25 -18
View File
@@ -1,10 +1,17 @@
import {makeAutoObservable} from 'mobx'
import {bsky, AdxUri} from '@adxp/mock-api'
import * as Post from '../../third-party/api/src/client/types/app/bsky/feed/post'
import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from './root-store'
import {cleanError} from '../../lib/strings'
export type PostEntities = bsky.Post.Record['entities']
export type PostReply = bsky.Post.Record['reply']
export class PostModel implements bsky.Post.Record {
type RemoveIndex<T> = {
[P in keyof T as string extends P
? never
: number extends P
? never
: P]: T[P]
}
export class PostModel implements RemoveIndex<Post.Record> {
// state
isLoading = false
hasLoaded = false
@@ -13,8 +20,8 @@ export class PostModel implements bsky.Post.Record {
// data
text: string = ''
entities?: PostEntities
reply?: PostReply
entities?: Post.Entity[]
reply?: Post.ReplyRef
createdAt: string = ''
constructor(public rootStore: RootStoreModel, uri: string) {
@@ -59,7 +66,7 @@ export class PostModel implements bsky.Post.Record {
private _xIdle(err: string = '') {
this.isLoading = false
this.hasLoaded = true
this.error = err
this.error = cleanError(err)
}
// loader functions
@@ -67,24 +74,24 @@ export class PostModel implements bsky.Post.Record {
private async _load() {
this._xLoading()
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const urip = new AdxUri(this.uri)
const res = await this.rootStore.api.mainPds
.repo(urip.host, false)
.collection(urip.collection)
.get('Post', urip.recordKey)
if (!res.valid) {
throw new Error(res.error)
}
const urip = new AtUri(this.uri)
const res = await this.rootStore.api.app.bsky.feed.post.get({
user: urip.host,
rkey: urip.rkey,
})
// TODO
// if (!res.valid) {
// throw new Error(res.error)
// }
this._replaceAll(res.value)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load post: ${e.toString()}`)
this._xIdle(e.toString())
}
}
private _replaceAll(res: bsky.Post.Record) {
private _replaceAll(res: Post.Record) {
this.text = res.text
this.entities = res.entities
this.reply = res.reply
+156
View File
@@ -0,0 +1,156 @@
import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from './root-store'
import {ProfileViewModel} from './profile-view'
import {MembersViewModel} from './members-view'
import {MembershipsViewModel} from './memberships-view'
import {FeedModel} from './feed-view'
export enum Sections {
Posts = 'Posts',
PostsWithReplies = 'Posts & replies',
Scenes = 'Scenes',
Trending = 'Trending',
Members = 'Members',
}
const USER_SELECTOR_ITEMS = [
Sections.Posts,
Sections.PostsWithReplies,
Sections.Scenes,
]
const SCENE_SELECTOR_ITEMS = [Sections.Trending, Sections.Members]
export interface ProfileUiParams {
user: string
}
export class ProfileUiModel {
// data
profile: ProfileViewModel
feed: FeedModel
memberships: MembershipsViewModel
members: MembersViewModel
// ui state
selectedViewIndex = 0
constructor(
public rootStore: RootStoreModel,
public params: ProfileUiParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.profile = new ProfileViewModel(rootStore, {actor: params.user})
this.feed = new FeedModel(rootStore, 'author', {
author: params.user,
limit: 10,
})
this.memberships = new MembershipsViewModel(rootStore, {actor: params.user})
this.members = new MembersViewModel(rootStore, {actor: params.user})
}
get currentView(): FeedModel | MembershipsViewModel | MembersViewModel {
if (
this.selectedView === Sections.Posts ||
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.Trending
) {
return this.feed
}
if (this.selectedView === Sections.Scenes) {
return this.memberships
}
if (this.selectedView === Sections.Members) {
return this.members
}
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
}
get isInitialLoading() {
const view = this.currentView
return view.isLoading && !view.isRefreshing && !view.hasContent
}
get isRefreshing() {
return this.profile.isRefreshing || this.currentView.isRefreshing
}
get isUser() {
return this.profile.isUser
}
get isScene() {
return this.profile.isScene
}
get selectorItems() {
if (this.isUser) {
return USER_SELECTOR_ITEMS
} else if (this.isScene) {
return SCENE_SELECTOR_ITEMS
} else {
return USER_SELECTOR_ITEMS
}
}
get selectedView() {
return this.selectorItems[this.selectedViewIndex]
}
// public api
// =
setSelectedViewIndex(index: number) {
this.selectedViewIndex = index
}
async setup() {
await Promise.all([
this.profile
.setup()
.catch(err => console.error('Failed to fetch profile', err)),
this.feed
.setup()
.catch(err => console.error('Failed to fetch feed', err)),
])
if (this.isUser) {
await this.memberships
.setup()
.catch(err => console.error('Failed to fetch members', err))
}
if (this.isScene) {
await this.members
.setup()
.catch(err => console.error('Failed to fetch members', err))
}
}
async update() {
const view = this.currentView
if (view instanceof FeedModel) {
await view.update()
} else {
await view.refresh()
}
}
async refresh() {
await Promise.all([this.profile.refresh(), this.currentView.refresh()])
}
async loadMore() {
if (
!this.currentView.isLoading &&
!this.currentView.hasError &&
!this.currentView.isEmpty
) {
await this.currentView.loadMore()
}
}
}
+98 -38
View File
@@ -1,38 +1,59 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {bsky} from '@adxp/mock-api'
import {Image as PickedImage} from 'react-native-image-crop-picker'
import * as GetProfile from '../../third-party/api/src/client/types/app/bsky/actor/getProfile'
import * as Profile from '../../third-party/api/src/client/types/app/bsky/actor/profile'
import {Main as DeclRef} from '../../third-party/api/src/client/types/app/bsky/system/declRef'
import {Entity} from '../../third-party/api/src/client/types/app/bsky/feed/post'
import {extractEntities} from '../../lib/strings'
import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api'
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
export const ACTOR_TYPE_SCENE = 'app.bsky.system.actorScene'
export class ProfileViewMyStateModel {
hasFollowed: boolean = false
follow?: string
member?: string
constructor() {
makeAutoObservable(this)
}
}
export class ProfileViewModel implements bsky.ProfileView.Response {
export class ProfileViewModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: bsky.ProfileView.Params
params: GetProfile.QueryParams
// data
did: string = ''
name: string = ''
displayName: string = ''
description: string = ''
handle: string = ''
declaration: DeclRef = {
cid: '',
actorType: '',
}
creator: string = ''
displayName?: string
description?: string
avatar?: string
followersCount: number = 0
followsCount: number = 0
membersCount: number = 0
postsCount: number = 0
badges: bsky.ProfileView.Badge[] = []
myState = new ProfileViewMyStateModel()
// TODO TEMP data to be implemented in the protocol
userBanner: string | null = null
// added data
descriptionEntities?: Entity[]
constructor(
public rootStore: RootStoreModel,
params: bsky.ProfileView.Params,
params: GetProfile.QueryParams,
) {
makeAutoObservable(
this,
@@ -57,6 +78,14 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
return this.hasLoaded && !this.hasContent
}
get isUser() {
return this.declaration.actorType === ACTOR_TYPE_USER
}
get isScene() {
return this.declaration.actorType === ACTOR_TYPE_SCENE
}
// public api
// =
@@ -65,30 +94,57 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
}
async refresh() {
await this._load()
await this._load(true)
}
async toggleFollowing() {
if (this.myState.hasFollowed) {
await apilib.unfollow(this.rootStore.api, 'alice.com', {
did: this.did,
})
if (!this.rootStore.me.did) {
throw new Error('Not logged in')
}
if (this.myState.follow) {
await apilib.unfollow(this.rootStore, this.myState.follow)
runInAction(() => {
this.followersCount--
this.myState.hasFollowed = false
this.myState.follow = undefined
})
} else {
await apilib.follow(this.rootStore.api, 'alice.com', {
did: this.did,
name: this.name,
})
const res = await apilib.follow(
this.rootStore,
this.did,
this.declaration.cid,
)
runInAction(() => {
this.followersCount++
this.myState.hasFollowed = true
this.myState.follow = res.uri
})
}
}
async updateProfile(
updates: Profile.Record,
newUserAvatar: PickedImage | undefined,
userBanner: string | null, // TODO TEMP
) {
// TODO TEMP add userBanner to the protocol when suported
this.userBanner = userBanner
if (newUserAvatar) {
const res = await this.rootStore.api.com.atproto.blob.upload(
newUserAvatar.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{
encoding: newUserAvatar.mime,
},
)
updates.avatar = {
cid: res.data.cid,
mimeType: newUserAvatar.mime,
}
}
await this.rootStore.api.app.bsky.actor.updateProfile(updates)
await this.rootStore.me.load()
await this.refresh()
}
// state transitions
// =
@@ -108,32 +164,36 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
// loader functions
// =
private async _load() {
this._xLoading()
await new Promise(r => setTimeout(r, 250)) // DEBUG
private async _load(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:ProfileView',
const res = await this.rootStore.api.app.bsky.actor.getProfile(
this.params,
)) as bsky.ProfileView.Response
)
this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`)
this._xIdle(e.toString())
}
}
private _replaceAll(res: bsky.ProfileView.Response) {
this.did = res.did
this.name = res.name
this.displayName = res.displayName
this.description = res.description
this.followersCount = res.followersCount
this.followsCount = res.followsCount
this.postsCount = res.postsCount
this.badges = res.badges
if (res.myState) {
Object.assign(this.myState, res.myState)
private _replaceAll(res: GetProfile.Response) {
console.log(res.data)
this.did = res.data.did
this.handle = res.data.handle
Object.assign(this.declaration, res.data.declaration)
this.creator = res.data.creator
this.displayName = res.data.displayName
this.description = res.data.description
this.avatar = res.data.avatar
this.followersCount = res.data.followersCount
this.followsCount = res.data.followsCount
this.membersCount = res.data.membersCount
this.postsCount = res.data.postsCount
if (res.data.myState) {
Object.assign(this.myState, res.data.myState)
}
this.descriptionEntities = extractEntities(this.description || '')
}
}
+50
View File
@@ -0,0 +1,50 @@
import {makeAutoObservable} from 'mobx'
import {LRUMap} from 'lru_map'
import {RootStoreModel} from './root-store'
import * as GetProfile from '../../third-party/api/src/client/types/app/bsky/actor/getProfile'
type CacheValue = Promise<GetProfile.Response> | GetProfile.Response
export class ProfilesViewModel {
cache: LRUMap<string, CacheValue> = new LRUMap(100)
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
cache: false,
},
{autoBind: true},
)
}
// public api
// =
async getProfile(did: string) {
const cached = this.cache.get(did)
if (cached) {
try {
return await cached
} catch (e) {
// ignore, we'll try again
}
}
try {
const promise = this.rootStore.api.app.bsky.actor.getProfile({actor: did})
this.cache.set(did, promise)
const res = await promise
this.cache.set(did, res)
return res
} catch (e) {
this.cache.delete(did)
throw e
}
}
overwrite(did: string, res: GetProfile.Response) {
if (this.cache.has(did)) {
this.cache.set(did, res)
}
}
}
+15 -16
View File
@@ -1,17 +1,19 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {bsky, AdxUri} from '@adxp/mock-api'
import {AtUri} from '../../third-party/uri'
import * as GetRepostedBy from '../../third-party/api/src/client/types/app/bsky/feed/getRepostedBy'
import {Main as DeclRef} from '../../third-party/api/src/client/types/app/bsky/system/declRef'
import {RootStoreModel} from './root-store'
type RepostedByItem = bsky.RepostedByView.Response['repostedBy'][number]
export class RepostedByViewItemModel implements RepostedByItem {
export class RepostedByViewItemModel implements GetRepostedBy.RepostedBy {
// ui state
_reactKey: string = ''
// data
did: string = ''
name: string = ''
handle: string = ''
displayName: string = ''
avatar?: string
declaration: DeclRef = {cid: '', actorType: ''}
createdAt?: string
indexedAt: string = ''
@@ -22,14 +24,14 @@ export class RepostedByViewItemModel implements RepostedByItem {
}
}
export class RepostedByViewModel implements bsky.RepostedByView.Response {
export class RepostedByViewModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
resolvedUri = ''
params: bsky.RepostedByView.Params
params: GetRepostedBy.QueryParams
// data
uri: string = ''
@@ -37,7 +39,7 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
constructor(
public rootStore: RootStoreModel,
params: bsky.RepostedByView.Params,
params: GetRepostedBy.QueryParams,
) {
makeAutoObservable(
this,
@@ -100,7 +102,7 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
// =
private async _resolveUri() {
const urip = new AdxUri(this.params.uri)
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
urip.host = await this.rootStore.resolveName(urip.host)
}
@@ -111,12 +113,10 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:RepostedByView',
const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(
Object.assign({}, this.params, {uri: this.resolvedUri}),
)) as bsky.RepostedByView.Response
)
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
@@ -127,14 +127,13 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
private async _refresh() {
this._xLoading(true)
// TODO: refetch and update items
await new Promise(r => setTimeout(r, 250)) // DEBUG
this._xIdle()
}
private _replaceAll(res: bsky.RepostedByView.Response) {
private _replaceAll(res: GetRepostedBy.Response) {
this.repostedBy.length = 0
let counter = 0
for (const item of res.repostedBy) {
for (const item of res.data.repostedBy) {
this._append(counter++, item)
}
}
+60 -12
View File
@@ -3,21 +3,29 @@
*/
import {makeAutoObservable} from 'mobx'
import {adx, AdxClient} from '@adxp/mock-api'
import {sessionClient as AtpApi} from '../../third-party/api'
import type {SessionServiceClient} from '../../third-party/api/src/index'
import {createContext, useContext} from 'react'
import {isObj, hasProp} from '../lib/type-guards'
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 {MeModel} from './me'
import {FeedViewModel} from './feed-view'
import {NotificationsViewModel} from './notifications-view'
import {OnboardModel} from './onboard'
import {isNetworkError} from '../../lib/errors'
export class RootStoreModel {
session = new SessionModel()
session = new SessionModel(this)
nav = new NavigationModel()
shell = new ShellUiModel()
me = new MeModel(this)
homeFeed = new FeedViewModel(this, {})
notesFeed = new NotificationsViewModel(this, {})
onboard = new OnboardModel()
profiles = new ProfilesViewModel(this)
linkMetas = new LinkMetasViewModel(this)
constructor(public api: AdxClient) {
constructor(public api: SessionServiceClient) {
makeAutoObservable(this, {
api: false,
resolveName: false,
@@ -26,15 +34,40 @@ export class RootStoreModel {
})
}
async resolveName(didOrName: string) {
const userDb = this.api.mockDb.getUser(didOrName)
if (!userDb) throw new Error(`User not found: ${didOrName}`)
return userDb.did
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
}
async fetchStateUpdate() {
if (!this.session.hasSession) {
return
}
try {
if (!this.session.online) {
await this.session.connect()
}
await this.me.fetchStateUpdate()
} catch (e: unknown) {
if (isNetworkError(e)) {
this.session.setOnline(false) // connection lost
}
console.error('Failed to fetch latest state', e)
}
}
serialize(): unknown {
return {
session: this.session.serialize(),
me: this.me.serialize(),
nav: this.nav.serialize(),
onboard: this.onboard.serialize(),
}
}
@@ -43,11 +76,26 @@ export class RootStoreModel {
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}
if (hasProp(v, 'me')) {
this.me.hydrate(v.me)
}
if (hasProp(v, 'nav')) {
this.nav.hydrate(v.nav)
}
if (hasProp(v, 'onboard')) {
this.onboard.hydrate(v.onboard)
}
}
}
clearAll() {
this.session.clear()
this.nav.clear()
this.me.clear()
}
}
const throwawayInst = new RootStoreModel(adx) // this will be replaced by the loader
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 RootStoreContext = createContext<RootStoreModel>(throwawayInst)
export const RootStoreProvider = RootStoreContext.Provider
export const useStores = () => useContext(RootStoreContext)
+230 -85
View File
@@ -1,109 +1,254 @@
import {makeAutoObservable} from 'mobx'
import {sessionClient as AtpApi} from '../../third-party/api/index'
import type {
SessionServiceClient,
Session,
} from '../../third-party/api/src/index'
import type * as GetAccountsConfig from '../../third-party/api/src/client/types/com/atproto/server/getAccountsConfig'
import {isObj, hasProp} from '../lib/type-guards'
// import {UserConfig} from '../../api'
// import * as auth from '../lib/auth'
import {RootStoreModel} from './root-store'
import {isNetworkError} from '../../lib/errors'
export type ServiceDescription = GetAccountsConfig.OutputSchema
interface SessionData {
service: string
refreshJwt: string
accessJwt: string
handle: string
did: string
}
export class SessionModel {
isAuthed = false
data: SessionData | null = null
online = false
attemptingConnect = false
private _connectPromise: Promise<void> | undefined
constructor() {
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
rootStore: false,
serialize: false,
hydrate: false,
})
}
get hasSession() {
return this.data !== null
}
serialize(): unknown {
return {
isAuthed: this.isAuthed,
data: this.data,
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'isAuthed') && typeof v.isAuthed === 'boolean') {
this.isAuthed = v.isAuthed
if (hasProp(v, 'data') && isObj(v.data)) {
const data: SessionData = {
service: '',
refreshJwt: '',
accessJwt: '',
handle: '',
did: '',
}
if (hasProp(v.data, 'service') && typeof v.data.service === 'string') {
data.service = v.data.service
}
if (
hasProp(v.data, 'refreshJwt') &&
typeof v.data.refreshJwt === 'string'
) {
data.refreshJwt = v.data.refreshJwt
}
if (
hasProp(v.data, 'accessJwt') &&
typeof v.data.accessJwt === 'string'
) {
data.accessJwt = v.data.accessJwt
}
if (hasProp(v.data, 'handle') && typeof v.data.handle === 'string') {
data.handle = v.data.handle
}
if (hasProp(v.data, 'did') && typeof v.data.did === 'string') {
data.did = v.data.did
}
if (
data.service &&
data.refreshJwt &&
data.accessJwt &&
data.handle &&
data.did
) {
this.data = data
}
}
}
}
setAuthed(v: boolean) {
this.isAuthed = v
clear() {
this.data = null
}
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,
})
}
}
private configureApi(): boolean {
if (!this.data) {
return false
}
try {
const serviceUri = new URL(this.data.service)
this.rootStore.api.xrpc.uri = serviceUri
} catch (e) {
console.error(
`Invalid service URL: ${this.data.service}. Resetting session.`,
)
console.error(e)
this.clear()
return false
}
this.rootStore.api.sessionManager.set({
refreshJwt: this.data.refreshJwt,
accessJwt: this.data.accessJwt,
})
return true
}
async connect(): Promise<void> {
if (this._connectPromise) {
return this._connectPromise
}
this._connectPromise = this._connect()
await this._connectPromise
this._connectPromise = undefined
}
private async _connect(): Promise<void> {
this.attemptingConnect = true
if (!this.configureApi()) {
return
}
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 => {
console.error('Failed to fetch local user information', e)
})
return // success
}
} catch (e: any) {
if (isNetworkError(e)) {
this.setOnline(false, false) // connection issue
return
} else {
this.clear() // invalid session cached
}
}
this.setOnline(false, false)
}
async describeService(service: string): Promise<ServiceDescription> {
const api = AtpApi.service(service) as SessionServiceClient
const res = await api.com.atproto.server.getAccountsConfig({})
return res.data
}
async login({
service,
handle,
password,
}: {
service: string
handle: 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.rootStore.me.load().catch(e => {
console.error('Failed to fetch local user information', e)
})
}
}
async createAccount({
service,
email,
password,
handle,
inviteCode,
}: {
service: string
email: string
password: string
handle: string
inviteCode?: string
}) {
const api = AtpApi.service(service) as SessionServiceClient
const res = await api.com.atproto.account.create({
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 => {
console.error('Failed to fetch local user information', e)
})
}
}
async logout() {
if (this.hasSession) {
this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
console.error('(Minor issue) Failed to delete session on the server', e)
})
}
this.rootStore.clearAll()
}
}
// TODO
/*login: flow(function* () {
/*self.uiIsProcessing = true
self.uiError = undefined
try {
if (!self.env.authStore) {
throw new Error('Auth store not initialized')
}
const res = yield auth.requestAppUcan(self.env.authStore)
self.isAuthed = res
self.uiIsProcessing = false
return res
} catch (e: any) {
console.error('Failed to request app ucan', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
}
}),
logout: flow(function* () {
self.uiIsProcessing = true
self.uiError = undefined
try {
if (!self.env.authStore) {
throw new Error('Auth store not initialized')
}
const res = yield auth.logout(self.env.authStore)
self.isAuthed = false
self.uiIsProcessing = false
return res
} catch (e: any) {
console.error('Failed to log out', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
}
}),
loadAccount: flow(function* () {
self.uiIsProcessing = true
self.uiError = undefined
try {
// const cfg = yield UserConfig.hydrate({
// serverUrl: self.serverUrl,
// secretKeyStr: self.secretKeyStr,
// rootAuthToken: self.rootAuthToken,
// })
// self.env.api.setUserCfg(cfg)
self.isAuthed = true
self.uiIsProcessing = false
return true
} catch (e: any) {
console.error('Failed to create test account', e)
self.uiError = e.toString()
self.uiIsProcessing = false
return false
}
}),
createTestAccount: flow(function* (_serverUrl: string) {
self.uiIsProcessing = true
self.uiError = undefined
try {
// const cfg = yield UserConfig.createTest(serverUrl)
// const state = yield cfg.serialize()
// self.serverUrl = state.serverUrl
// self.secretKeyStr = state.secretKeyStr
// self.rootAuthToken = state.rootAuthToken
self.isAuthed = true
// self.env.api.setUserCfg(cfg)
} catch (e: any) {
console.error('Failed to create test account', e)
self.uiError = e.toString()
}
self.uiIsProcessing = false
}),
}))*/
+114
View File
@@ -0,0 +1,114 @@
import {makeAutoObservable} from 'mobx'
import {ProfileViewModel} from './profile-view'
export class ConfirmModel {
name = 'confirm'
constructor(
public title: string,
public message: string | (() => JSX.Element),
public onPressConfirm: () => void | Promise<void>,
) {
makeAutoObservable(this)
}
}
export class EditProfileModel {
name = 'edit-profile'
constructor(
public profileView: ProfileViewModel,
public onUpdate?: () => void,
) {
makeAutoObservable(this)
}
}
export class CreateSceneModel {
name = 'create-scene'
constructor() {
makeAutoObservable(this)
}
}
export class InviteToSceneModel {
name = 'invite-to-scene'
constructor(public profileView: ProfileViewModel) {
makeAutoObservable(this)
}
}
export class ServerInputModel {
name = 'server-input'
constructor(
public initialService: string,
public onSelect: (url: string) => void,
) {
makeAutoObservable(this)
}
}
export interface ComposerOptsPostRef {
uri: string
cid: string
text: string
author: {
handle: string
displayName?: string
avatar?: string
}
}
export interface ComposerOpts {
replyTo?: ComposerOptsPostRef
onPost?: () => void
}
export class ShellUiModel {
isMainMenuOpen = false
isModalActive = false
activeModal:
| ConfirmModel
| EditProfileModel
| CreateSceneModel
| ServerInputModel
| undefined
isComposerActive = false
composerOpts: ComposerOpts | undefined
constructor() {
makeAutoObservable(this)
}
setMainMenuOpen(v: boolean) {
this.isMainMenuOpen = v
}
openModal(
modal:
| ConfirmModel
| EditProfileModel
| CreateSceneModel
| ServerInputModel,
) {
this.isModalActive = true
this.activeModal = modal
}
closeModal() {
this.isModalActive = false
this.activeModal = undefined
}
openComposer(opts: ComposerOpts) {
this.isComposerActive = true
this.composerOpts = opts
}
closeComposer() {
this.isComposerActive = false
this.composerOpts = undefined
}
}
+113
View File
@@ -0,0 +1,113 @@
import {makeAutoObservable} from 'mobx'
import * as GetSuggestions from '../../third-party/api/src/client/types/app/bsky/actor/getSuggestions'
import {RootStoreModel} from './root-store'
export type SuggestedActor = GetSuggestions.Actor & {
_reactKey: string
}
export class SuggestedActorsViewModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
// data
suggestions: SuggestedActor[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.suggestions.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async setup() {
await this._fetch()
}
async refresh() {
await this._fetch(true)
}
// state transitions
// =
private _xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
private _xIdle(err: string = '') {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
}
// loader functions
// =
private async _fetch(isRefreshing = false) {
this.suggestions.length = 0
this._xLoading(isRefreshing)
let cursor
let res
try {
do {
res = await this.rootStore.api.app.bsky.actor.getSuggestions({
limit: 20,
cursor,
})
this._appendAll(res)
cursor = res.data.cursor
} while (
cursor &&
res.data.actors.length === 20 &&
this.suggestions.length < 20
)
this._xIdle()
} catch (e: any) {
this._xIdle(e.toString())
}
}
private _appendAll(res: GetSuggestions.Response) {
for (const item of res.data.actors) {
if (item.did === this.rootStore.me.did) {
continue // skip self
}
if (item.myState?.follow) {
continue // skip already-followed users
}
this._append({
_reactKey: `item-${this.suggestions.length}`,
...item,
})
}
}
private _append(item: SuggestedActor) {
this.suggestions.push(item)
}
}
+133
View File
@@ -0,0 +1,133 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from './root-store'
import {UserFollowsViewModel, FollowItem} from './user-follows-view'
import {GetAssertionsView} from './get-assertions-view'
import {APP_BSKY_SYSTEM, APP_BSKY_GRAPH} from '../../third-party/api'
export interface SuggestedInvitesViewParams {
sceneDid: string
}
export class SuggestedInvitesView {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: SuggestedInvitesViewParams
sceneAssertionsView: GetAssertionsView
myFollowsView: UserFollowsViewModel
// data
suggestions: FollowItem[] = []
constructor(
public rootStore: RootStoreModel,
params: SuggestedInvitesViewParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
this.sceneAssertionsView = new GetAssertionsView(rootStore, {
author: params.sceneDid,
assertion: APP_BSKY_GRAPH.AssertMember,
})
this.myFollowsView = new UserFollowsViewModel(rootStore, {
user: rootStore.me.did || '',
})
}
get hasContent() {
return this.suggestions.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get unconfirmed() {
return this.sceneAssertionsView.unconfirmed
}
// public api
// =
async setup() {
await this._fetch(false)
}
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: string = '') {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
}
// loader functions
// =
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
// TODO need to fetch all!
await this.sceneAssertionsView.setup()
} catch (e) {
console.error(e)
this._xIdle(
'Failed to fetch the current scene members. Check your internet connection and try again.',
)
return
}
try {
await this.myFollowsView.setup()
} catch (e) {
console.error(e)
this._xIdle(
'Failed to fetch the your current followers. Check your internet connection and try again.',
)
return
}
// collect all followed users that arent already in the scene
const newSuggestions: FollowItem[] = []
for (const follow of this.myFollowsView.follows) {
if (follow.declaration.actorType !== APP_BSKY_SYSTEM.ActorUser) {
continue
}
if (!this.sceneAssertionsView.getBySubject(follow.did)) {
newSuggestions.push(follow)
}
}
runInAction(() => {
this.suggestions = newSuggestions
})
this._xIdle()
}
}
@@ -0,0 +1,99 @@
import {makeAutoObservable, runInAction} from 'mobx'
import * as GetFollows from '../../third-party/api/src/client/types/app/bsky/graph/getFollows'
import * as SearchTypeahead from '../../third-party/api/src/client/types/app/bsky/actor/searchTypeahead'
import {RootStoreModel} from './root-store'
export class UserAutocompleteViewModel {
// state
isLoading = false
isActive = false
prefix = ''
_searchPromise: Promise<any> | undefined
// data
follows: GetFollows.Follow[] = []
searchRes: SearchTypeahead.User[] = []
knownHandles: Set<string> = new Set()
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
knownHandles: false,
},
{autoBind: true},
)
}
get suggestions() {
if (!this.isActive) {
return []
}
if (this.prefix) {
return this.searchRes.map(user => ({
handle: user.handle,
displayName: user.displayName,
avatar: user.avatar,
}))
}
return this.follows.map(follow => ({
handle: follow.handle,
displayName: follow.displayName,
avatar: follow.avatar,
}))
}
// public api
// =
async setup() {
await this._getFollows()
}
setActive(v: boolean) {
this.isActive = v
}
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
}
this._searchPromise = this._search()
} else {
this.searchRes = []
}
}
// internal
// =
private async _getFollows() {
const res = await this.rootStore.api.app.bsky.graph.getFollows({
user: this.rootStore.me.did || '',
})
runInAction(() => {
this.follows = res.data.follows
for (const f of this.follows) {
this.knownHandles.add(f.handle)
}
})
}
private async _search() {
const res = await this.rootStore.api.app.bsky.actor.searchTypeahead({
term: this.prefix,
limit: 8,
})
runInAction(() => {
this.searchRes = res.data.users
for (const u of this.searchRes) {
this.knownHandles.add(u.handle)
}
})
}
}
+21 -17
View File
@@ -1,26 +1,31 @@
import {makeAutoObservable} from 'mobx'
import {bsky} from '@adxp/mock-api'
import * as GetFollowers from '../../third-party/api/src/client/types/app/bsky/graph/getFollowers'
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
import {RootStoreModel} from './root-store'
type Subject = bsky.UserFollowersView.Response['subject']
export type FollowerItem =
bsky.UserFollowersView.Response['followers'][number] & {_reactKey: string}
export type FollowerItem = GetFollowers.Follower & {
_reactKey: string
}
export class UserFollowersViewModel implements bsky.UserFollowersView.Response {
export class UserFollowersViewModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: bsky.UserFollowersView.Params
params: GetFollowers.QueryParams
// data
subject: Subject = {did: '', name: '', displayName: ''}
subject: ActorRef.WithInfo = {
did: '',
handle: '',
declaration: {cid: '', actorType: ''},
}
followers: FollowerItem[] = []
constructor(
public rootStore: RootStoreModel,
params: bsky.UserFollowersView.Params,
params: GetFollowers.QueryParams,
) {
makeAutoObservable(
this,
@@ -81,12 +86,10 @@ export class UserFollowersViewModel implements bsky.UserFollowersView.Response {
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:UserFollowersView',
const res = await this.rootStore.api.app.bsky.graph.getFollowers(
this.params,
)) as bsky.UserFollowersView.Response
)
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
@@ -94,13 +97,14 @@ export class UserFollowersViewModel implements bsky.UserFollowersView.Response {
}
}
private _replaceAll(res: bsky.UserFollowersView.Response) {
this.subject.did = res.subject.did
this.subject.name = res.subject.name
this.subject.displayName = res.subject.displayName
private _replaceAll(res: GetFollowers.Response) {
this.subject.did = res.data.subject.did
this.subject.handle = res.data.subject.handle
this.subject.displayName = res.data.subject.displayName
this.subject.avatar = res.data.subject.avatar
this.followers.length = 0
let counter = 0
for (const item of res.followers) {
for (const item of res.data.followers) {
this._append({_reactKey: `item-${counter++}`, ...item})
}
}
+19 -16
View File
@@ -1,27 +1,31 @@
import {makeAutoObservable} from 'mobx'
import {bsky} from '@adxp/mock-api'
import * as GetFollows from '../../third-party/api/src/client/types/app/bsky/graph/getFollows'
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
import {RootStoreModel} from './root-store'
type Subject = bsky.UserFollowsView.Response['subject']
export type FollowItem = bsky.UserFollowsView.Response['follows'][number] & {
export type FollowItem = GetFollows.Follow & {
_reactKey: string
}
export class UserFollowsViewModel implements bsky.UserFollowsView.Response {
export class UserFollowsViewModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: bsky.UserFollowsView.Params
params: GetFollows.QueryParams
// data
subject: Subject = {did: '', name: '', displayName: ''}
subject: ActorRef.WithInfo = {
did: '',
handle: '',
declaration: {cid: '', actorType: ''},
}
follows: FollowItem[] = []
constructor(
public rootStore: RootStoreModel,
params: bsky.UserFollowsView.Params,
params: GetFollows.QueryParams,
) {
makeAutoObservable(
this,
@@ -82,12 +86,10 @@ export class UserFollowsViewModel implements bsky.UserFollowsView.Response {
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:UserFollowsView',
const res = await this.rootStore.api.app.bsky.graph.getFollows(
this.params,
)) as bsky.UserFollowsView.Response
)
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
@@ -95,13 +97,14 @@ export class UserFollowsViewModel implements bsky.UserFollowsView.Response {
}
}
private _replaceAll(res: bsky.UserFollowsView.Response) {
this.subject.did = res.subject.did
this.subject.name = res.subject.name
this.subject.displayName = res.subject.displayName
private _replaceAll(res: GetFollows.Response) {
this.subject.did = res.data.subject.did
this.subject.handle = res.data.subject.handle
this.subject.displayName = res.data.subject.displayName
this.subject.avatar = res.data.subject.avatar
this.follows.length = 0
let counter = 0
for (const item of res.follows) {
for (const item of res.data.follows) {
this._append({_reactKey: `item-${counter++}`, ...item})
}
}
+27
View File
@@ -0,0 +1,27 @@
import {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll'
import {makeAutoObservable, runInAction} from 'mobx'
import {CameraRoll} from '@react-native-camera-roll/camera-roll'
import {RootStoreModel} from './root-store'
export class UserLocalPhotosModel {
// state
photos: PhotoIdentifier[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
rootStore: false,
})
}
async setup() {
await this._getPhotos()
}
private async _getPhotos() {
CameraRoll.getPhotos({first: 20}).then(r => {
runInAction(() => {
this.photos = r.edges
})
})
}
}
@@ -1,44 +1,44 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {bsky, AdxUri} from '@adxp/mock-api'
import {AtUri} from '../../third-party/uri'
import * as GetVotes from '../../third-party/api/src/client/types/app/bsky/feed/getVotes'
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
import {RootStoreModel} from './root-store'
type LikedByItem = bsky.LikedByView.Response['likedBy'][number]
export class LikedByViewItemModel implements LikedByItem {
export class VotesViewItemModel implements GetVotes.Vote {
// ui state
_reactKey: string = ''
// data
did: string = ''
name: string = ''
displayName: string = ''
createdAt?: string
direction: 'up' | 'down' = 'up'
indexedAt: string = ''
createdAt: string = ''
actor: ActorRef.WithInfo = {
did: '',
handle: '',
declaration: {cid: '', actorType: ''},
}
constructor(reactKey: string, v: LikedByItem) {
constructor(reactKey: string, v: GetVotes.Vote) {
makeAutoObservable(this)
this._reactKey = reactKey
Object.assign(this, v)
}
}
export class LikedByViewModel implements bsky.LikedByView.Response {
export class VotesViewModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
resolvedUri = ''
params: bsky.LikedByView.Params
params: GetVotes.QueryParams
// data
uri: string = ''
likedBy: LikedByViewItemModel[] = []
votes: VotesViewItemModel[] = []
constructor(
public rootStore: RootStoreModel,
params: bsky.LikedByView.Params,
) {
constructor(public rootStore: RootStoreModel, params: GetVotes.QueryParams) {
makeAutoObservable(
this,
{
@@ -100,7 +100,7 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
// =
private async _resolveUri() {
const urip = new AdxUri(this.params.uri)
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
urip.host = await this.rootStore.resolveName(urip.host)
}
@@ -111,12 +111,10 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:LikedByView',
const res = await this.rootStore.api.app.bsky.feed.getVotes(
Object.assign({}, this.params, {uri: this.resolvedUri}),
)) as bsky.LikedByView.Response
)
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
@@ -124,15 +122,15 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
}
}
private _replaceAll(res: bsky.LikedByView.Response) {
this.likedBy.length = 0
private _replaceAll(res: GetVotes.Response) {
this.votes.length = 0
let counter = 0
for (const item of res.likedBy) {
for (const item of res.data.votes) {
this._append(counter++, item)
}
}
private _append(keyId: number, item: LikedByItem) {
this.likedBy.push(new LikedByViewItemModel(`item-${keyId}`, item))
private _append(keyId: number, item: GetVotes.Vote) {
this.votes.push(new VotesViewItemModel(`item-${keyId}`, item))
}
}

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