Compare commits

...

131 Commits

Author SHA1 Message Date
Paul Frazee 7a8304131b Update tests 2023-01-17 10:11:20 -06:00
Paul Frazee c6f2465ba4 Merge branch 'main' into remove-scenes 2023-01-17 10:08:38 -06:00
João Ferreiro d636ec6339 Unit Testing (#35)
* add testing lib

* remove coverage folder from git

* finished basic test setup

* fix tests typescript and import paths

* add first snapshot

* testing utils

* rename test files; update script flags; ++tests

* testing utils functions

* testing downloadAndResize wip

* remove download test

* specify unwanted coverage paths;
remove update snapshots flag

* fix strings tests

* testing downloadAndResize method

* increasing testing

* fixing snapshots wip

* fixed shell mobile snapshot

* adding snapshots for the screens

* fix onboard snapshot

* fix typescript issues

* fix TabsSelector snapshot

* Account for testing device's locale in ago() tests

* Remove platform detection on regex

* mocking store state wip

* mocking store state

* increasing test coverage

* increasing test coverage

* increasing test coverage on src/screens

* src/screens (except for profile) above 80% cov

* testing profile screen wip

* increase coverage on Menu and TabsSelector

* mocking profile ui state wip

* mocking profile ui state wip

* fixing mobileshell tests wip

* snapshots using testing-library

* fixing profile tests wip

* removing mobile shell tests

* src/view/com tests wip

* remove unnecessary patch-package

* fixed profile test error

* clear mocks after every test

* fix base mocked store values (getters)

* fix base mocked store values
(hasLoaded, nonReplyFeed)

* profile screen above 80% coverage

* testing custom hooks

* improving composer coverage

* fix tests after merge

* finishing composer coverage

* improving src/com/discover coverage

* improve src/view/com/login coverage
fix SuggestedFollows tests
adding some comments

* fix SuggestedFollows tests

* improve src/view/com/profile coverage
extra minor fixes

* improve src/view/com/notifications coverage

* update coverage ignore patterns

* rename errorMessageTryAgainButton
increase SuggestedFollows converage

* improve src/view/com/posts coverage

* improve src/view/com/onboard coverage

* update snapshot

* improve src/view/com/post coverage

* improve src/view/com/post-thread coverage
rename ErrorMessage tests
test Debug and Log components

* init testing state

* testing root-store

* updating comments

* small fixes

* removed extra console logs

* improve src/state/models coverage
refactor rootStore
rename some spies

* adding cleanup method after tests

* improve src/state/models coverage

* improve src/state/models coverage

* improve src/state/models coverage

* improve src/state/models coverage

* test setInterval in setupState

* Clean up tests and update Home screen state management

* Remove some tests we dont need

* Remove snapshot tests

* Remove any tests that dont demonstrate clear value

* Cleanup

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2023-01-17 10:06:00 -06:00
Paul Frazee 98c9a6ad32 Remove the avi from the compose prompt 2023-01-16 17:37:18 -06:00
Paul Frazee 33a4d2cbe5 Enlarge the view header 2023-01-16 17:21:35 -06:00
Paul Frazee 8a5bc60819 Dark mode color fixes 2023-01-16 17:11:15 -06:00
Paul Frazee f492fd4d41 Readd the FAB for composing new posts 2023-01-16 17:07:33 -06:00
Paul Frazee ed7204ab71 Round the corners on single-image embeds a bit more 2023-01-16 16:58:30 -06:00
Paul Frazee 9aeaa38f3b More spacing and sizing tweaks, including larger post controls 2023-01-16 16:54:35 -06:00
Paul Frazee b3f9ed7981 Enlarge post text slightly 2023-01-16 16:42:07 -06:00
Paul Frazee b8ab064fcc More spacing fixes, especially for the repost info 2023-01-16 16:34:52 -06:00
Paul Frazee 58bf9cb8cd Spacing fixes 2023-01-16 16:30:05 -06:00
Paul Frazee 8a67e8d102 Fix visual consistency of 'reply to' in post and use display name 2023-01-16 16:24:16 -06:00
Paul Frazee 5d6fc56349 Fix missing metrics in post threads 2023-01-16 16:22:24 -06:00
Paul Frazee 77ed2129d3 Move menu controls into post footers and improve meta info rendering 2023-01-16 16:09:51 -06:00
Aryan Goharzad fcebe8e0b0 Readme Cleanup (#40)
* Readme Cleanup

Updates instructions for starting the dev/staging servers

* Update README.md

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2023-01-16 15:40:34 -06:00
Paul Frazee 6df617003a Fix: remove duplicates in the TL caused by rendering reply parents 2023-01-16 14:49:58 -06:00
Paul Frazee 878d577cab Remove scene-related API helpers 2023-01-16 14:05:32 -06:00
Paul Frazee c11d9ef64b Remove scene-related code from posts feed 2023-01-16 14:05:04 -06:00
Paul Frazee beb74aee37 Remove all scenes-related items from notifications 2023-01-16 14:03:48 -06:00
Paul Frazee 6d58dc9db3 Remove member/membership code 2023-01-16 14:00:46 -06:00
Paul Frazee c7758c33d1 Remove scene-related modals 2023-01-16 13:57:44 -06:00
Paul Frazee 840373fcf4 Remove 'scenes explainer' from onboarding flow 2023-01-16 13:54:25 -06:00
Paul Frazee ff7f73cd8c Remove scenes from the profile view 2023-01-16 13:50:29 -06:00
Paul Frazee fd6ebf3dfe Remove scenes from the main menu 2023-01-16 13:46:40 -06:00
Paul Frazee 6953bf9d22 Fix image compression for avis and banner images 2023-01-12 15:07:32 -06:00
Paul Frazee 9396595595 Add scrolling to menu 2023-01-03 14:35:33 -06:00
Paul Frazee cf13b5cd4c Bump deps to pull in some bugfixes 2023-01-03 14:31:12 -06:00
Paul Frazee e24ab48b73 Indicate images in notifications 2023-01-03 13:14:10 -06:00
Paul Frazee bbee08eb22 Implement validation and proper type detection 2023-01-03 13:08:56 -06:00
Paul Frazee c68f291128 Improve handling around connection failures during login 2023-01-03 12:17:07 -06:00
Paul Frazee 32b3575074 Add 'is 13' checkbox to account creation 2023-01-03 11:49:40 -06:00
Paul Frazee 11f972c640 Fix to feed update 2023-01-03 11:26:51 -06:00
Paul Frazee 61cf754fd5 Fix session hydration during load 2023-01-03 11:08:39 -06:00
Paul Frazee 00b50c71b5 Fix bug in error handling 2023-01-03 11:08:29 -06:00
Paul Frazee 66eb6f2888 Introduce error boundaries around all screens 2023-01-02 21:49:14 -06:00
Paul Frazee bc571ee998 Improve error logging 2023-01-02 21:39:15 -06:00
Paul Frazee c758996034 Bump api@0.0.5 and use the type guard helpers 2023-01-02 20:56:35 -06:00
Paul Frazee 1f26494c94 Implement logging system 2023-01-02 17:38:13 -06:00
Paul Frazee 74bfc10e36 Dont mute posts for a user when viewing their profile 2023-01-02 13:45:27 -06:00
Paul Frazee e194e5de91 Implement account muting 2023-01-02 13:40:14 -06:00
Paul Frazee 09f96da982 Fix to loading state 2022-12-30 15:58:28 -06:00
Paul Frazee 6e48d63dda Fix the status bar coloring 2022-12-30 15:56:54 -06:00
Paul Frazee eec636897c Add dark mode toggle 2022-12-30 15:48:34 -06:00
Paul Frazee 777f30562b Integrate composer into design system 2022-12-30 15:42:09 -06:00
Paul Frazee 16e4bc66bd Various small fixes 2022-12-30 15:32:07 -06:00
Paul Frazee 98c5312a29 Integrate search into suggested follows 2022-12-30 15:21:09 -06:00
Paul Frazee 9971f1f035 Dont autofocus the search input 2022-12-30 15:12:36 -06:00
Paul Frazee dec9d97171 Integrate settings into design system and some various fixes/tweaks 2022-12-30 15:11:47 -06:00
Paul Frazee 355749e605 Integrate menu into design system 2022-12-30 15:08:10 -06:00
Paul Frazee 6268736bfa Integrate notifications into the design system 2022-12-30 13:34:10 -06:00
Paul Frazee a270d750c8 Integrate profile listings into design system 2022-12-30 13:20:55 -06:00
Paul Frazee 0eb4c7495e Update profile header to use design system 2022-12-30 13:13:10 -06:00
Paul Frazee b438318b92 Integrate the shell into the design system 2022-12-30 12:49:07 -06:00
Paul Frazee 01aaa6123f Integrate ViewSelector into design system 2022-12-30 12:40:16 -06:00
Paul Frazee 486a1f082c Integrate composer prompt into design system 2022-12-30 12:35:37 -06:00
Paul Frazee f943cbd04a Integrate ViewHeader into the design system 2022-12-30 12:29:11 -06:00
Paul Frazee c54fa5b2e4 Update threads to use design system 2022-12-30 12:19:45 -06:00
Paul Frazee 54c0115181 Improve image layout 2022-12-30 11:55:25 -06:00
Paul Frazee bb3a2fddfc Tune post controls 2022-12-28 19:37:30 -06:00
Paul Frazee 5135a13812 Update posts in feed to use the theming system 2022-12-28 19:20:02 -06:00
Paul Frazee 059c3b362d Update notifications 2022-12-28 15:53:06 -06:00
Paul Frazee 090ceb4c53 Tighten up spacing and dividers 2022-12-28 15:50:28 -06:00
Paul Frazee 7796203c25 Fix letter spacing 2022-12-28 15:08:14 -06:00
Paul Frazee 44ae514d89 Add a design system (#34)
* Add theming system

* Add standard Button control and update RadioButtons

* Unify radiobutton with design system

* Update debug screen to have multiple views

* Add ToggleButton

* Update error controls to use design system

* Add typography to <Text> element

* Move DropdownButton into the design system

* Clean out old code

* Move Text into design system

* Add 'inverted' color palette

* Move LoadingPlaceholder into the design system
2022-12-28 14:06:01 -06:00
Paul Frazee 12d8e06a1e Increase image resolutions 2022-12-26 17:41:12 -06:00
Paul Frazee de65d9198a Start with highest quality compression and find a suitable size (#33) 2022-12-26 12:01:40 -06:00
Paul Frazee 41bf8e15cd Bump @atproto/api@0.0.3 2022-12-22 14:43:54 -06:00
João Ferreiro adeb1d5cec Unit testing (#32)
* add testing lib

* remove coverage folder from git

* finished basic test setup

* fix tests typescript and import paths

* add first snapshot

* testing utils

* rename test files; update script flags; ++tests

* testing utils functions

* testing downloadAndResize wip

* remove download test

* specify unwanted coverage paths;
remove update snapshots flag

* fix strings tests

* testing downloadAndResize method

* increasing testing

* fixing snapshots wip

* fixed shell mobile snapshot

* adding snapshots for the screens

* fix onboard snapshot

* fix typescript issues

* fix TabsSelector snapshot

* Account for testing device's locale in ago() tests

* Remove platform detection on regex

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2022-12-22 09:32:39 -06:00
Paul Frazee 61511d5161 Unvendor the api and switch to @atproto/api 2022-12-22 09:17:07 -06:00
Paul Frazee aee205b0ab Fix issue which caused parent posts to fail at mobx reactivity 2022-12-21 14:37:22 -06:00
Paul Frazee e59763b50a Update to use new getTimeline, getAuthorFeed, and getPostThread output models 2022-12-20 22:54:56 -06:00
Paul Frazee 5168d44926 Replace removed hasIndexes behavior 2022-12-20 16:32:04 -06:00
Paul Frazee def3156c33 Get the composer into a passable visual state on android when the keyboard is open 2022-12-20 14:55:50 -06:00
Paul Frazee a290891f70 Fix post-meta truncation on android 2022-12-20 14:50:04 -06:00
Paul Frazee a96562e89c Fixes to text input colors on android 2022-12-20 14:29:50 -06:00
Paul Frazee 279d41fcd1 Wrap all <Text> in a helper that ensures black color is applied by default 2022-12-20 12:49:44 -06:00
Paul Frazee 4932505c5e Use consistent rendering of avis and post metas in threads 2022-12-20 10:33:56 -06:00
Paul Frazee 629b7d8c9b Proper fix to the previous panic fix: put threads at the end when they should be there 2022-12-20 09:20:53 -06:00
Paul Frazee 5c71f8e32a Fix to error condition that breaks the feed 2022-12-20 09:02:38 -06:00
Paul Frazee 395a8838be A couple more android updates 2022-12-19 19:43:28 -06:00
João Ferreiro 17903b817d rn-fetch-blob android setup 2022-12-19 19:26:54 -06:00
João Ferreiro 54f05bfcf6 minor fix 2022-12-19 19:26:54 -06:00
João Ferreiro b1a058d098 fixing remaining text colors on android 2022-12-19 19:25:58 -06:00
João Ferreiro 27657541f2 fixing modal & keyboard interaction on android 2022-12-19 19:23:24 -06:00
João Ferreiro 73ea67f5c0 initial android fixes 2022-12-19 19:20:44 -06:00
João Ferreiro d065b62f52 fix camera permissions 2022-12-19 19:19:00 -06:00
João Ferreiro eb03170736 fix android runtime 2022-12-19 19:19:00 -06:00
João Ferreiro 6f734f7978 fix android build 2022-12-19 19:19:00 -06:00
Paul Frazee 9235d6c13b Remove prefetch of feed (it wasnt working) 2022-12-19 19:16:56 -06:00
Paul Frazee bc54c8d7c6 Tighten up the post control hit regions 2022-12-19 19:13:26 -06:00
Paul Frazee 3b8dbbff12 Tighten up the home feed 2022-12-19 19:12:43 -06:00
Paul Frazee e8bb545023 Enable image-only posts 2022-12-19 18:51:13 -06:00
Paul Frazee f0b30e26d4 Fix to thread ordering in the feed: correctly identify the starts of threads when adjacent 2022-12-19 13:22:18 -06:00
Paul Frazee cbfa9f62a4 Improve thread rendering 2022-12-18 18:54:05 -06:00
Paul Frazee fefd8cc8a0 Add WIP 'report account' modal 2022-12-18 17:45:23 -06:00
Paul Frazee 09765a5d36 Add WIP 'report post' modal 2022-12-18 17:28:28 -06:00
Paul Frazee e325b72c62 Reduce confusing visual feedback while scrolling 2022-12-18 16:43:18 -06:00
Paul Frazee 055a4d8e98 Add zooming to the lightbox 2022-12-16 16:32:54 -06:00
Paul Frazee 1e28ae6d2a Prefetch feed refreshes to speed up 'load more posts' 2022-12-16 15:37:13 -06:00
Paul Frazee 16343a2936 Fixes to the composer UX around images and scrolling 2022-12-16 14:48:37 -06:00
Paul Frazee 18cc2183cc Add swipe gestures to the lightbox 2022-12-16 11:57:45 -06:00
Paul Frazee 5e711c799b Implement 'forgot password' flow 2022-12-15 17:45:03 -06:00
Paul Frazee ca4b9d3a60 Split login screen into component files 2022-12-15 14:06:05 -06:00
Paul Frazee ebd03ffe2a Just do full renders of parent posts in the feed 2022-12-15 11:43:05 -06:00
Paul Frazee ee4f0673f1 Dont use ??= 2022-12-15 10:21:25 -06:00
Paul Frazee 33145f2e71 Improve handling of connection loss 2022-12-14 16:03:10 -06:00
Paul Frazee 721fd84a7e Fix state-tracking 2022-12-14 15:49:45 -06:00
Paul Frazee a06fd7dfd4 Reset feeds on login 2022-12-14 15:49:38 -06:00
Paul Frazee bd6bb4df06 Add haptic feedback to likes and reposts 2022-12-14 15:46:21 -06:00
Paul Frazee bb4dc3c3a5 Add post embeds (images and external links) 2022-12-14 15:35:15 -06:00
Paul Frazee 7b7fe7ccd8 Move posts feed to root store (me) and ensure new posts always update the feed 2022-12-13 15:52:18 -06:00
Paul Frazee 316679ae68 Fix: avoid dup keynames in posts feed 2022-12-13 15:51:54 -06:00
Paul Frazee b34cae6db4 Enlarge feed avis slightly 2022-12-13 15:40:55 -06:00
Paul Frazee 52e31394ab Ensure load more is always visible 2022-12-13 15:36:05 -06:00
Paul Frazee 84f18e03fa Use avatar in viewheader when cant go back 2022-12-13 15:35:45 -06:00
Paul Frazee 30761594ec Fix: avoid duplicate key names in notifications 2022-12-13 15:35:11 -06:00
Paul Frazee 87258a2439 Fix issue that caused repost & trend data to get lost on feed updates 2022-12-13 15:17:18 -06:00
Paul Frazee 6e31e928a9 Hide main menu drawer on click outside 2022-12-13 15:01:14 -06:00
Paul Frazee d0587c81f8 Use full available screen 2022-12-13 14:58:25 -06:00
Paul Frazee 29018f06b9 Fix: show avatars in all notifications 2022-12-13 14:53:00 -06:00
Paul Frazee 171cb6b3d3 Debug: disable feed reordering to isolate bugs 2022-12-13 11:15:32 -06:00
Paul Frazee 3e37c27310 Reposition 'load latest' based on minimal shell mode 2022-12-13 11:15:18 -06:00
Paul Frazee 06c3ebe2d4 Hide footer on scroll down (minimal shell mode) 2022-12-12 16:04:14 -06:00
Paul Frazee d336cd58b1 Improvements to service selection during login 2022-12-12 15:13:45 -06:00
Paul Frazee 684f67a8e0 Add profile image lightbox 2022-12-12 14:54:56 -06:00
Paul Frazee a7b23613d3 Improve session management 2022-12-12 14:39:02 -06:00
Paul Frazee a6db1a9ee3 Add banner image upload to profiles 2022-12-12 13:47:18 -06:00
Paul Frazee 0f065b2c27 Improvements to 'edit profile' error messages 2022-12-12 13:17:40 -06:00
Paul Frazee 7399e37bba Add 'loading more' spinner to feeds 2022-12-12 11:22:54 -06:00
Paul Frazee 7ce4c821e5 Tune the swipe gesture to detect more easily 2022-12-12 11:11:37 -06:00
252 changed files with 11376 additions and 16206 deletions
+3
View File
@@ -59,3 +59,6 @@ buck-out/
# Ruby / CocoaPods
/ios/Pods/
/vendor/bundle/
# Testing
coverage/
+4 -2
View File
@@ -16,8 +16,10 @@ Uses:
- After initial setup:
- `cd ios ; pod install`
- Start the dev servers
- `yarn dev-pds`
- `yarn dev-wallet`
- `git clone git@github.com:bluesky-social/atproto.git`
- `cd atproto`
- `yarn`
- `cd packages/dev-env && yarn start`
- Run the dev app
- iOS: `yarn ios`
- Android: `yarn android`
@@ -0,0 +1,9 @@
export const CameraRoll = {
getPhotos: jest.fn().mockResolvedValue({
edges: [
{node: {image: {uri: 'path/to/image1.jpg'}}},
{node: {image: {uri: 'path/to/image2.jpg'}}},
{node: {image: {uri: 'path/to/image3.jpg'}}},
],
}),
}
-1
View File
@@ -1 +0,0 @@
export default from '@react-native-async-storage/async-storage/jest/async-storage-mock'
+9
View File
@@ -0,0 +1,9 @@
export const openPicker = jest
.fn()
.mockImplementation(() => Promise.resolve({uri: ''}))
export const openCamera = jest
.fn()
.mockImplementation(() => Promise.resolve({uri: ''}))
export const openCropper = jest
.fn()
.mockImplementation(() => Promise.resolve({uri: ''}))
+10
View File
@@ -0,0 +1,10 @@
jest.mock('rn-fetch-blob', () => {
return {
__esModule: true,
default: {
fs: {
unlink: jest.fn(),
},
},
}
})
+934
View File
@@ -0,0 +1,934 @@
import {LogModel} from './../src/state/models/log'
import {LRUMap} from 'lru_map'
import {RootStoreModel} from './../src/state/models/root-store'
import {NavigationTabModel} from './../src/state/models/navigation'
import {SessionModel} from '../src/state/models/session'
import {NavigationModel} from '../src/state/models/navigation'
import {ShellUiModel} from '../src/state/models/shell-ui'
import {MeModel} from '../src/state/models/me'
import {OnboardModel} from '../src/state/models/onboard'
import {ProfilesViewModel} from '../src/state/models/profiles-view'
import {LinkMetasViewModel} from '../src/state/models/link-metas-view'
import {MembershipsViewModel} from '../src/state/models/memberships-view'
import {FeedModel} from '../src/state/models/feed-view'
import {NotificationsViewModel} from '../src/state/models/notifications-view'
import {ProfileViewModel} from '../src/state/models/profile-view'
import {MembersViewModel} from '../src/state/models/members-view'
import {ProfileUiModel, Sections} from '../src/state/models/profile-ui'
import {SessionServiceClient} from '@atproto/api'
import {UserAutocompleteViewModel} from '../src/state/models/user-autocomplete-view'
import {UserLocalPhotosModel} from '../src/state/models/user-local-photos'
import {SuggestedActorsViewModel} from '../src/state/models/suggested-actors-view'
import {UserFollowersViewModel} from '../src/state/models/user-followers-view'
import {UserFollowsViewModel} from '../src/state/models/user-follows-view'
import {NotificationsViewItemModel} from './../src/state/models/notifications-view'
import {
PostThreadViewModel,
PostThreadViewPostModel,
} from '../src/state/models/post-thread-view'
import {FeedItemModel} from '../src/state/models/feed-view'
import {RepostedByViewModel} from '../src/state/models/reposted-by-view'
import {VotesViewModel} from '../src/state/models/votes-view'
export const mockedProfileStore = {
isLoading: false,
isRefreshing: false,
hasLoaded: true,
error: '',
params: {
actor: '',
},
did: 'test did',
handle: 'testhandle',
declaration: {
cid: '',
actorType: '',
},
creator: 'test did',
displayName: '',
description: '',
avatar: '',
banner: '',
followersCount: 0,
followsCount: 0,
membersCount: 0,
postsCount: 0,
myState: {
follow: '',
member: '',
},
rootStore: {} as RootStoreModel,
hasContent: true,
hasError: false,
isEmpty: false,
isUser: true,
isScene: false,
setup: jest.fn().mockResolvedValue({aborted: false}),
refresh: jest.fn(),
toggleFollowing: jest.fn().mockResolvedValue({}),
updateProfile: jest.fn(),
// unknown required because of the missing private methods: _xLoading, _xIdle, _load, _replaceAll
} as unknown as ProfileViewModel
export const mockedMembersStore = {
isLoading: false,
isRefreshing: false,
hasLoaded: true,
error: '',
params: {
actor: 'test actor',
},
subject: {
did: 'test did',
handle: '',
displayName: '',
declaration: {
cid: '',
actorType: '',
},
avatar: undefined,
},
members: [
{
did: 'test did2',
declaration: {
cid: '',
actorType: '',
},
handle: 'testhandle',
displayName: 'test name',
indexedAt: '',
},
],
rootStore: {} as RootStoreModel,
hasContent: true,
hasError: false,
isEmpty: false,
isMember: jest.fn(),
setup: jest.fn().mockResolvedValue({aborted: false}),
refresh: jest.fn(),
loadMore: jest.fn(),
removeMember: jest.fn(),
// unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append
} as unknown as MembersViewModel
export const mockedMembershipsStore = {
isLoading: false,
isRefreshing: false,
hasLoaded: true,
error: '',
params: {
actor: '',
limit: 1,
before: '',
},
subject: {
did: 'test did',
handle: '',
displayName: '',
declaration: {cid: '', actorType: ''},
avatar: undefined,
},
memberships: [
{
did: 'test did',
declaration: {
cid: '',
actorType: 'app.bsky.system.actorUser',
},
handle: ',',
displayName: '',
createdAt: '',
indexedAt: '',
_reactKey: 'item-1',
},
],
rootStore: {} as RootStoreModel,
hasContent: true,
hasError: false,
isEmpty: false,
isMemberOf: jest.fn(),
setup: jest.fn().mockResolvedValue({aborted: false}),
refresh: jest.fn(),
loadMore: jest.fn(),
// unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append
} as unknown as MembershipsViewModel
export const mockedFeedItemStore = {
_reactKey: 'item-1',
_isThreadParent: false,
_isThreadChildElided: false,
_isThreadChild: false,
post: {
uri: 'testuri',
cid: 'test cid',
author: {
did: 'test did',
handle: 'test.handle',
displayName: 'test name',
declaration: {cid: '', actorType: ''},
},
record: {
$type: 'app.bsky.feed.post',
createdAt: '2022-12-29T16:39:57.919Z',
text: 'Sup',
},
replyCount: 0,
repostCount: 0,
upvoteCount: 0,
downvoteCount: 0,
indexedAt: '2022-12-29T16:39:57.919Z',
viewer: {},
},
postRecord: {
$type: 'app.bsky.feed.post',
text: 'test text',
createdAt: '1',
reply: {
root: {
uri: 'testuri',
cid: 'tes cid',
},
parent: {
uri: 'testuri',
cid: 'tes cid',
},
},
},
rootStore: {} as RootStoreModel,
copy: jest.fn(),
toggleUpvote: jest.fn().mockResolvedValue({}),
toggleDownvote: jest.fn(),
toggleRepost: jest.fn().mockResolvedValue({}),
delete: jest.fn().mockResolvedValue({}),
reasonRepost: {
by: {
did: 'test did',
handle: 'test.handle',
declaration: {cid: '', actorType: ''},
},
indexedAt: '',
},
reasonTrend: {
by: {
did: 'test did',
handle: 'test.handle',
declaration: {cid: '', actorType: ''},
},
indexedAt: '',
},
reply: {
parent: {
author: {
did: 'test did',
handle: 'test.handle',
displayName: 'test name',
declaration: {cid: '', actorType: ''},
},
cid: '',
downvoteCount: 0,
indexedAt: '2023-01-10T11:17:46.945Z',
record: {},
replyCount: 1,
repostCount: 0,
upvoteCount: 0,
uri: 'testuri',
viewer: {},
},
root: {
author: {
did: 'test did',
handle: 'test.handle',
displayName: 'test name',
declaration: {cid: '', actorType: ''},
},
cid: '',
downvoteCount: 0,
indexedAt: '2023-01-10T11:17:46.739Z',
record: {},
replyCount: 1,
repostCount: 0,
upvoteCount: 1,
uri: 'testuri',
viewer: {},
},
},
} as FeedItemModel
export const mockedFeedStore = {
isLoading: false,
isRefreshing: false,
hasNewLatest: false,
hasLoaded: true,
error: '',
hasMore: true,
params: {
actor: '',
limit: 1,
before: '',
},
feed: [],
rootStore: {} as RootStoreModel,
feedType: 'home',
hasContent: true,
hasError: false,
isEmpty: false,
nonReplyFeed: [
{
_reactKey: 'item-1',
post: {
author: {
handle: 'handle.test',
displayName: 'test name',
avatar: '',
},
cid: 'bafyreihkwjoy2vbfqld2lp3tv4ce6yfr354sqgp32qoplrudso4gyyjiwe',
downvoteCount: 0,
indexedAt: '2022-12-29T16:35:55.270Z',
record: {
$type: 'app.bsky.feed.post',
createdAt: '2022-12-29T16:39:57.919Z',
text: 'Sup',
},
replyCount: 0,
repostCount: 0,
upvoteCount: 0,
uri: 'at://did:plc:wcizmlgv3rdslk64t6q4silu/app.bsky.feed.post/3jkzce5kfvn2h',
viewer: {
handle: 'handle.test',
displayName: 'test name',
avatar: '',
},
},
reason: undefined,
reply: undefined,
},
],
setHasNewLatest: jest.fn(),
setup: jest.fn().mockResolvedValue({}),
refresh: jest.fn().mockResolvedValue({}),
loadMore: jest.fn().mockResolvedValue({}),
loadLatest: jest.fn(),
update: jest.fn(),
checkForLatest: jest.fn().mockRejectedValue('Error checking for latest'),
// unknown required because of the missing private methods: _xLoading, _xIdle, _pendingWork, _initialLoad, _loadLatest, _loadMore, _update, _replaceAll, _appendAll, _prependAll, _updateAll, _getFeed, loadMoreCursor, pollCursor, _loadPromise, _updatePromise, _loadLatestPromise, _loadMorePromise
} as unknown as FeedModel
export const mockedPostThreadViewPostStore = {
_reactKey: 'item-1',
_depth: 0,
_isHighlightedPost: false,
_hasMore: false,
postRecord: {
text: 'test text',
createdAt: '',
reply: {
root: {
uri: 'testuri',
cid: 'tes cid',
},
parent: {
uri: 'testuri',
cid: 'tes cid',
},
},
},
post: {
uri: 'testuri',
cid: 'testcid',
record: {},
author: {
did: 'test did',
handle: 'test.handle',
declaration: {cid: '', actorType: ''},
viewer: {
muted: true,
},
},
replyCount: 0,
repostCount: 0,
upvoteCount: 0,
downvoteCount: 0,
indexedAt: '',
viewer: {
repost: '',
upvote: '',
downvote: '',
},
},
rootStore: {} as RootStoreModel,
assignTreeModels: jest.fn(),
toggleRepost: jest.fn().mockRejectedValue({}),
toggleUpvote: jest.fn().mockRejectedValue({}),
toggleDownvote: jest.fn(),
delete: jest.fn(),
} as PostThreadViewPostModel
export const mockedPostThreadViewStore = {
isLoading: false,
isRefreshing: false,
hasLoaded: false,
error: '',
notFound: false,
resolvedUri: 'testuri',
params: {
uri: 'testuri',
},
thread: mockedPostThreadViewPostStore,
hasContent: true,
hasError: false,
setup: jest.fn(),
refresh: jest.fn().mockResolvedValue({}),
update: jest.fn(),
// unknown required because of the missing private methods: _xLoading, _xIdle, _resolveUri, _load, _replaceAll
} as unknown as PostThreadViewModel
export const mockedNotificationsViewItemStore = {
_reactKey: 'item-1',
uri: 'testuri',
cid: '',
author: {
did: 'test did',
handle: 'test.handle',
declaration: {cid: '', actorType: ''},
},
rootStore: {} as RootStoreModel,
copy: jest.fn(),
reason: 'test reason',
isRead: true,
indexedAt: '',
isUpvote: true,
isRepost: false,
isTrend: false,
isMention: false,
isReply: false,
isFollow: false,
isAssertion: false,
needsAdditionalData: false,
isInvite: false,
subjectUri: 'testuri',
toSupportedRecord: jest.fn().mockReturnValue({
text: 'test text',
createdAt: '',
}),
fetchAdditionalData: jest.fn(),
} as NotificationsViewItemModel
export const mockedNotificationsStore = {
isLoading: false,
isRefreshing: false,
hasLoaded: true,
error: '',
params: {
limit: 1,
before: '',
},
hasMore: true,
notifications: [mockedNotificationsViewItemStore],
rootStore: {} as RootStoreModel,
hasContent: true,
hasError: false,
isEmpty: false,
setup: jest.fn().mockResolvedValue({aborted: false}),
refresh: jest.fn().mockResolvedValue({}),
loadMore: jest.fn().mockResolvedValue({}),
update: jest.fn().mockResolvedValue(null),
updateReadState: jest.fn(),
// unknown required because of the missing private methods: _xLoading, _xIdle, _pendingWork, _initialLoad, _loadMore, _update, _replaceAll, _appendAll, _updateAll, loadMoreCursor, _loadPromise, _updatePromise, _loadLatestPromise, _loadMorePromise
} as unknown as NotificationsViewModel
export const mockedSessionStore = {
serialize: jest.fn(),
hydrate: jest.fn(),
data: {
service: '',
refreshJwt: '',
accessJwt: '',
handle: '',
did: 'test did',
},
online: false,
attemptingConnect: false,
rootStore: {} as RootStoreModel,
hasSession: true,
clear: jest.fn(),
setState: jest.fn(),
setOnline: jest.fn(),
updateAuthTokens: jest.fn(),
connect: jest.fn(),
describeService: jest.fn().mockResolvedValue({
availableUserDomains: ['test'],
links: {
termsOfService: 'https://testTermsOfService',
privacyPolicy: 'https://testPrivacyPolicy',
},
}),
login: jest.fn(),
createAccount: jest.fn(),
logout: jest.fn(),
// unknown required because of the missing private methods: _connectPromise, configureApi & _connect
} as unknown as SessionModel
export const mockedNavigationTabStore = {
serialize: jest.fn(),
hydrate: jest.fn(),
id: 0,
history: [
{
url: '',
ts: 0,
title: '',
id: 0,
},
],
index: 0,
isNewTab: false,
current: {
url: '',
ts: 0,
title: '',
id: 0,
},
canGoBack: false,
canGoForward: false,
backTen: [
{
url: '',
title: '',
index: 0,
id: 0,
},
],
forwardTen: [
{
url: '',
title: '',
index: 0,
id: 0,
},
],
navigate: jest.fn(),
refresh: jest.fn(),
goBack: jest.fn(),
fixedTabReset: jest.fn(),
goForward: jest.fn(),
goToIndex: jest.fn(),
setTitle: jest.fn(),
setIsNewTab: jest.fn(),
fixedTabPurpose: 0,
getBackList: () => [
{
url: '/',
title: '',
index: 1,
id: 1,
},
],
getForwardList: jest.fn(),
} as NavigationTabModel
export const mockedNavigationStore = {
serialize: jest.fn(),
hydrate: jest.fn(),
tabs: [mockedNavigationTabStore],
tabIndex: 0,
clear: jest.fn(),
tab: mockedNavigationTabStore,
tabCount: 1,
isCurrentScreen: jest.fn(),
navigate: jest.fn(),
refresh: jest.fn(),
setTitle: jest.fn(),
handleLink: jest.fn(),
switchTo: jest.fn(),
setActiveTab: jest.fn(),
closeTab: jest.fn(),
newTab: jest.fn(),
} as NavigationModel
export const mockedShellStore = {
serialize: jest.fn(),
hydrate: jest.fn(),
minimalShellMode: false,
isMainMenuOpen: false,
isModalActive: false,
activeModal: undefined,
isLightboxActive: false,
activeLightbox: undefined,
isComposerActive: false,
composerOpts: undefined,
darkMode: false,
setDarkMode: jest.fn(),
setMainMenuOpen: jest.fn(),
setMinimalShellMode: jest.fn(),
openModal: jest.fn(),
closeModal: jest.fn(),
closeComposer: jest.fn(),
closeLightbox: jest.fn(),
openComposer: jest.fn(),
openLightbox: jest.fn(),
} as ShellUiModel
export const mockedMeStore = {
serialize: jest.fn(),
hydrate: jest.fn(),
did: 'test did',
handle: 'test',
displayName: 'test',
description: 'test',
avatar: '',
notificationCount: 0,
rootStore: {} as RootStoreModel,
memberships: mockedMembershipsStore,
mainFeed: mockedFeedStore,
notifications: mockedNotificationsStore,
clear: jest.fn(),
load: jest.fn(),
clearNotificationCount: jest.fn(),
fetchStateUpdate: jest.fn(),
refreshMemberships: jest.fn(),
} as MeModel
export const mockedOnboardStore = {
serialize: jest.fn(),
hydrate: jest.fn(),
isOnboarding: false,
stage: '',
start: jest.fn(),
stop: jest.fn(),
next: jest.fn(),
} as OnboardModel
export const mockedProfilesStore = {
hydrate: jest.fn(),
serialize: jest.fn(),
cache: new LRUMap(100),
rootStore: {} as RootStoreModel,
getProfile: jest.fn().mockResolvedValue({data: {}}),
overwrite: jest.fn(),
} as ProfilesViewModel
export const mockedLinkMetasStore = {
hydrate: jest.fn(),
serialize: jest.fn(),
cache: new LRUMap(100),
rootStore: {} as RootStoreModel,
getLinkMeta: jest.fn(),
} as LinkMetasViewModel
export const mockedLogStore = {
entries: [],
serialize: jest.fn(),
hydrate: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
// unknown required because of the missing private methods: add
} as unknown as LogModel
export const mockedRootStore = {
api: {
com: {},
app: {
bsky: {
graph: {
confirmation: {
delete: jest.fn().mockResolvedValue({}),
},
getFollowers: jest.fn().mockResolvedValue({}),
getMembers: jest.fn().mockResolvedValue({}),
},
},
},
} as unknown as SessionServiceClient,
resolveName: jest.fn(),
serialize: jest.fn(),
hydrate: jest.fn(),
fetchStateUpdate: jest.fn(),
clearAll: jest.fn(),
session: mockedSessionStore,
nav: mockedNavigationStore,
shell: mockedShellStore,
me: mockedMeStore,
onboard: mockedOnboardStore,
profiles: mockedProfilesStore,
linkMetas: mockedLinkMetasStore,
log: mockedLogStore,
} as RootStoreModel
export const mockedProfileUiStore = {
profile: mockedProfileStore,
feed: mockedFeedStore,
memberships: mockedMembershipsStore,
members: mockedMembersStore,
selectedViewIndex: 0,
rootStore: mockedRootStore,
params: {
user: 'test user',
},
currentView: mockedFeedStore,
isInitialLoading: false,
isRefreshing: false,
isUser: true,
isScene: false,
selectorItems: [Sections.Posts, Sections.PostsWithReplies, Sections.Scenes],
selectedView: Sections.Posts,
setSelectedViewIndex: jest.fn(),
setup: jest.fn().mockResolvedValue({aborted: false}),
update: jest.fn(),
refresh: jest.fn(),
loadMore: jest.fn(),
} as ProfileUiModel
export const mockedAutocompleteViewStore = {
isLoading: false,
isActive: true,
prefix: '',
follows: [
{
did: 'test did',
declaration: {
cid: '',
actorType: 'app.bsky.system.actorUser',
},
handle: '',
displayName: '',
createdAt: '',
indexedAt: '',
},
],
searchRes: [
{
did: 'test did',
declaration: {
cid: '',
actorType: 'app.bsky.system.actorUser',
},
handle: '',
displayName: '',
},
],
knownHandles: new Set<string>(),
suggestions: [
{
handle: 'handle.test',
displayName: 'Test Display',
},
{
handle: 'handle2.test',
displayName: 'Test Display 2',
},
],
rootStore: {} as RootStoreModel,
setup: jest.fn(),
setActive: jest.fn(),
setPrefix: jest.fn(),
// unknown required because of the missing private methods: _searchPromise, _getFollows , _search
} as unknown as UserAutocompleteViewModel
export const mockedLocalPhotosStore = {
photos: {
node: {
type: '',
group_name: '',
image: {
filename: '',
extension: '',
uri: '',
height: 1000,
width: 1000,
fileSize: null,
playableDuration: 0,
},
timestamp: 1672847197,
location: null,
},
},
rootStore: {} as RootStoreModel,
setup: jest.fn(),
// unknown required because of the missing private methods: _getPhotos
} as unknown as UserLocalPhotosModel
export const mockedSuggestedActorsStore = {
isLoading: false,
isRefreshing: false,
hasLoaded: false,
error: '',
suggestions: [
{
did: '1',
declaration: {
cid: '',
actorType: 'app.bsky.system.actorUser',
},
handle: 'handle1.test',
displayName: 'test name 1',
description: 'desc',
indexedAt: '',
_reactKey: '1',
},
{
did: '2',
declaration: {
cid: '',
actorType: 'app.bsky.system.actorUser',
},
handle: '',
displayName: 'handle2.test',
description: 'desc',
indexedAt: '',
_reactKey: '2',
},
],
rootStore: {} as RootStoreModel,
hasContent: true,
hasError: false,
isEmpty: false,
setup: jest.fn().mockResolvedValue(null),
refresh: jest.fn(),
// unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _appendAll, _append
} as unknown as SuggestedActorsViewModel
export const mockedUserFollowersStore = {
isLoading: false,
isRefreshing: false,
hasLoaded: false,
error: '',
params: {
user: 'test user',
},
subject: {
did: 'test did',
handle: '',
declaration: {cid: '', actorType: ''},
},
followers: [
{
did: 'test did',
declaration: {cid: '', actorType: ''},
handle: 'testhandle',
displayName: 'test name',
indexedAt: '',
_reactKey: '1',
},
{
did: 'test did2',
declaration: {cid: '', actorType: ''},
handle: 'testhandle2',
displayName: 'test name 2',
indexedAt: '',
_reactKey: '2',
},
],
rootStore: {} as RootStoreModel,
hasContent: true,
hasError: false,
isEmpty: false,
setup: jest.fn(),
refresh: jest.fn(),
loadMore: jest.fn(),
// unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append
} as unknown as UserFollowersViewModel
export const mockedUserFollowsStore = {
isLoading: false,
isRefreshing: false,
hasLoaded: false,
error: '',
params: {
user: 'test user',
},
subject: {
did: 'test did',
handle: '',
declaration: {cid: '', actorType: ''},
},
follows: [
{
did: 'test did',
declaration: {cid: '', actorType: ''},
handle: 'testhandle',
displayName: 'test name',
indexedAt: '',
_reactKey: '1',
},
{
did: 'test did2',
declaration: {cid: '', actorType: ''},
handle: 'testhandle2',
displayName: 'test name 2',
indexedAt: '',
_reactKey: '2',
},
],
rootStore: {} as RootStoreModel,
hasContent: true,
hasError: false,
isEmpty: false,
setup: jest.fn(),
refresh: jest.fn(),
loadMore: jest.fn(),
// unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append
} as unknown as UserFollowsViewModel
export const mockedRepostedByViewStore = {
isLoading: false,
isRefreshing: false,
hasLoaded: false,
error: '',
resolvedUri: '',
params: {
uri: 'testuri',
},
uri: '',
repostedBy: [
{
_reactKey: '',
did: '',
handle: '',
displayName: '',
declaration: {cid: '', actorType: ''},
indexedAt: '',
},
],
hasContent: false,
hasError: false,
isEmpty: false,
setup: jest.fn().mockResolvedValue({}),
refresh: jest.fn().mockResolvedValue({}),
loadMore: jest.fn().mockResolvedValue({}),
// unknown required because of the missing private methods: _xIdle, _xLoading, _resolveUri, _fetch, _refresh, _replaceAll, _append
} as unknown as RepostedByViewModel
export const mockedVotesViewStore = {
isLoading: false,
isRefreshing: false,
hasLoaded: false,
error: '',
resolvedUri: '',
params: {
uri: 'testuri',
},
uri: '',
votes: [
{
_reactKey: '',
direction: 'up',
indexedAt: '',
createdAt: '',
actor: {
did: '',
handle: '',
declaration: {cid: '', actorType: ''},
},
},
],
hasContent: false,
hasError: false,
isEmpty: false,
setup: jest.fn(),
refresh: jest.fn().mockResolvedValue({}),
loadMore: jest.fn().mockResolvedValue({}),
// unknown required because of the missing private methods: _xIdle, _xLoading, _resolveUri, _fetch, _replaceAll, _append
} as unknown as VotesViewModel
-16
View File
@@ -1,16 +0,0 @@
// /**
// * @format
// */
// 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'
// it('renders correctly', () => {
// renderer.act(() => {
// renderer.create(<App />)
// })
// })
+19
View File
@@ -0,0 +1,19 @@
import {isNetworkError} from '../../src/lib/errors'
describe('isNetworkError', () => {
const inputs = [
'TypeError: Network request failed',
'Uncaught TypeError: Cannot read property x of undefined',
'Uncaught RangeError',
'Error: Aborted',
]
const outputs = [true, false, false, true]
it('correctly distinguishes network errors', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const result = isNetworkError(input)
expect(result).toEqual(outputs[i])
}
})
})
+93
View File
@@ -0,0 +1,93 @@
import {downloadAndResize, DownloadAndResizeOpts} from '../../src/lib/images'
import ImageResizer from '@bam.tech/react-native-image-resizer'
import RNFetchBlob from 'rn-fetch-blob'
describe('downloadAndResize', () => {
const errorSpy = jest.spyOn(global.console, 'error')
const mockResizedImage = {
path: jest.fn().mockReturnValue('file://resized-image.jpg'),
size: 100,
width: 50,
height: 50,
mime: 'image/jpeg',
}
beforeEach(() => {
const mockedCreateResizedImage =
ImageResizer.createResizedImage as jest.Mock
mockedCreateResizedImage.mockResolvedValue(mockResizedImage)
})
afterEach(() => {
jest.clearAllMocks()
})
it('should return resized image for valid URI and options', async () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image.jpg'),
flush: jest.fn(),
})
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.jpg',
width: 100,
height: 100,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
const result = await downloadAndResize(opts)
expect(result).toEqual(mockResizedImage)
expect(RNFetchBlob.config).toHaveBeenCalledWith({
fileCache: true,
appendExt: 'jpeg',
})
expect(RNFetchBlob.fetch).toHaveBeenCalledWith(
'GET',
'https://example.com/image.jpg',
)
expect(ImageResizer.createResizedImage).toHaveBeenCalledWith(
'file://downloaded-image.jpg',
100,
100,
'JPEG',
1,
undefined,
undefined,
undefined,
{mode: 'cover'},
)
})
it('should return undefined for invalid URI', async () => {
const opts: DownloadAndResizeOpts = {
uri: 'invalid-uri',
width: 100,
height: 100,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
const result = await downloadAndResize(opts)
expect(errorSpy).toHaveBeenCalled()
expect(result).toBeUndefined()
})
it('should return undefined for unsupported file type', async () => {
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.bmp',
width: 100,
height: 100,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
const result = await downloadAndResize(opts)
expect(result).toBeUndefined()
})
})
@@ -1,4 +1,4 @@
import {LikelyType, getLinkMeta} from '../src/lib/link-meta'
import {LikelyType, getLinkMeta, getLikelyType} from '../../src/lib/link-meta'
const exampleComHtml = `<!doctype html>
<html>
@@ -57,7 +57,9 @@ describe('getLinkMeta', () => {
'https://example.com/image.png',
'https://example.com/video.avi',
'https://example.com/audio.ogg',
'https://example.com/text.txt',
'https://example.com/javascript.js',
'https://bsky.app/index.html',
]
const outputs = [
{
@@ -94,15 +96,29 @@ describe('getLinkMeta', () => {
likelyType: LikelyType.Audio,
url: 'https://example.com/audio.ogg',
},
{
likelyType: LikelyType.Text,
url: 'https://example.com/text.txt',
},
{
likelyType: LikelyType.Other,
url: 'https://example.com/javascript.js',
},
{
likelyType: LikelyType.AtpData,
url: '/index.html',
title: 'Not found',
},
{
likelyType: LikelyType.Other,
url: '',
title: '',
},
]
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) => {
return new Promise((resolve, _reject) => {
resolve({
ok: true,
status: 200,
@@ -116,3 +132,15 @@ describe('getLinkMeta', () => {
}
})
})
describe('getLikelyType', () => {
it('correctly handles non-parsed url', async () => {
const output = await getLikelyType('https://example.com')
expect(output).toEqual(LikelyType.HTML)
})
it('handles non-string urls without crashing', async () => {
const output = await getLikelyType('123')
expect(output).toEqual(LikelyType.Other)
})
})
+24
View File
@@ -0,0 +1,24 @@
import {clamp} from '../../src/lib/numbers'
describe('clamp', () => {
const inputs: [number, number, number][] = [
[100, 0, 200],
[100, 0, 100],
[0, 0, 100],
[100, 0, -1],
[4, 1, 1],
[100, -100, 0],
[400, 100, -100],
[70, -1, 1],
[Infinity, Infinity, Infinity],
]
const outputs = [100, 100, 0, -1, 1, 0, -100, 1, Infinity]
it('correctly clamps any given number and range', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const result = clamp(...input)
expect(result).toEqual(outputs[i])
}
})
})
@@ -2,7 +2,17 @@ import {
extractEntities,
detectLinkables,
extractHtmlMeta,
} from '../src/lib/strings'
pluralize,
makeRecordUri,
ago,
makeValidHandle,
createFullHandle,
enforceLen,
cleanError,
toNiceDomain,
toShortUrl,
toShareUrl,
} from '../../src/lib/strings'
describe('extractEntities', () => {
const knownHandles = new Set(['handle.com', 'full123.test-of-chars'])
@@ -317,3 +327,206 @@ describe('extractHtmlMeta', () => {
}
})
})
describe('pluralize', () => {
const inputs: [number, string, string?][] = [
[1, 'follower'],
[1, 'member'],
[100, 'post'],
[1000, 'repost'],
[10000, 'upvote'],
[100000, 'other'],
[2, 'man', 'men'],
]
const outputs = [
'follower',
'member',
'posts',
'reposts',
'upvotes',
'others',
'men',
]
it('correctly pluralizes a set of words', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const output = pluralize(...input)
expect(output).toEqual(outputs[i])
}
})
})
describe('makeRecordUri', () => {
const inputs: [string, string, string][] = [
['alice.test', 'app.bsky.feed.post', '3jk7x4irgv52r'],
]
const outputs = ['at://alice.test/app.bsky.feed.post/3jk7x4irgv52r']
it('correctly builds a record URI', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const result = makeRecordUri(...input)
expect(result).toEqual(outputs[i])
}
})
})
describe('ago', () => {
const inputs = [
1671461038,
'04 Dec 1995 00:12:00 GMT',
new Date(),
new Date().setMinutes(new Date().getMinutes() - 10),
new Date().setHours(new Date().getHours() - 1),
new Date().setDate(new Date().getDate() - 1),
new Date().setMonth(new Date().getMonth() - 1),
]
const outputs = [
new Date(1671461038).toLocaleDateString(),
new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
'0s',
'10m',
'1h',
'1d',
'1mo',
]
it('correctly calculates how much time passed, in a string', () => {
for (let i = 0; i < inputs.length; i++) {
const result = ago(inputs[i])
expect(result).toEqual(outputs[i])
}
})
})
describe('makeValidHandle', () => {
const inputs = [
'test-handle-123',
'test!"#$%&/()=?_',
'this-handle-should-be-too-big',
]
const outputs = ['test-handle-123', 'test', 'this-handle-should-b']
it('correctly parses and corrects handles', () => {
for (let i = 0; i < inputs.length; i++) {
const result = makeValidHandle(inputs[i])
expect(result).toEqual(outputs[i])
}
})
})
describe('createFullHandle', () => {
const inputs: [string, string][] = [
['test-handle-123', 'test'],
['.test.handle', 'test.test.'],
['test.handle.', '.test.test'],
]
const outputs = [
'test-handle-123.test',
'.test.handle.test.test.',
'test.handle.test.test',
]
it('correctly parses and corrects handles', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const result = createFullHandle(...input)
expect(result).toEqual(outputs[i])
}
})
})
describe('enforceLen', () => {
const inputs: [string, number][] = [
['Hello World!', 5],
['Hello World!', 20],
['', 5],
]
const outputs = ['Hello', 'Hello World!', '']
it('correctly enforces defined length on a given string', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const result = enforceLen(...input)
expect(result).toEqual(outputs[i])
}
})
})
describe('cleanError', () => {
const inputs = [
'TypeError: Network request failed',
'Error: Aborted',
'Error: TypeError "x" is not a function',
'Error: SyntaxError unexpected token "export"',
'Some other error',
]
const outputs = [
'Unable to connect. Please check your internet connection and try again.',
'Unable to connect. Please check your internet connection and try again.',
'TypeError "x" is not a function',
'SyntaxError unexpected token "export"',
'Some other error',
]
it('removes extra content from error message', () => {
for (let i = 0; i < inputs.length; i++) {
const result = cleanError(inputs[i])
expect(result).toEqual(outputs[i])
}
})
})
describe('toNiceDomain', () => {
const inputs = [
'https://example.com/index.html',
'https://bsky.app',
'https://bsky.social',
'#123123123',
]
const outputs = ['example.com', 'bsky.app', 'Bluesky Social', '#123123123']
it("displays the url's host in a easily readable manner", () => {
for (let i = 0; i < inputs.length; i++) {
const result = toNiceDomain(inputs[i])
expect(result).toEqual(outputs[i])
}
})
})
describe('toShortUrl', () => {
const inputs = [
'https://bsky.app',
'https://bsky.app/3jk7x4irgv52r',
'https://bsky.app/3jk7x4irgv52r2313y182h9',
]
const outputs = [
'bsky.app',
'bsky.app/3jk7x4irgv52r',
'bsky.app/3jk7x4irgv52r2313y...',
]
it('shortens the url', () => {
for (let i = 0; i < inputs.length; i++) {
const result = toShortUrl(inputs[i])
expect(result).toEqual(outputs[i])
}
})
})
describe('toShareUrl', () => {
const inputs = ['https://bsky.app', '/3jk7x4irgv52r', 'item/test/123']
const outputs = [
'https://bsky.app',
'https://bsky.app/3jk7x4irgv52r',
'https://bsky.app/item/test/123',
]
it('appends https, when not present', () => {
for (let i = 0; i < inputs.length; i++) {
const result = toShareUrl(inputs[i])
expect(result).toEqual(outputs[i])
}
})
})
@@ -0,0 +1,72 @@
import {RootStoreModel} from '../../../src/state/models/root-store'
import {LinkMetasViewModel} from '../../../src/state/models/link-metas-view'
import * as LinkMetaLib from '../../../src/lib/link-meta'
import {LikelyType} from './../../../src/lib/link-meta'
import {sessionClient, SessionServiceClient} from '@atproto/api'
import {DEFAULT_SERVICE} from '../../../src/state'
describe('LinkMetasViewModel', () => {
let viewModel: LinkMetasViewModel
let rootStore: RootStoreModel
const getLinkMetaMockSpy = jest.spyOn(LinkMetaLib, 'getLinkMeta')
const mockedMeta = {
title: 'Test Title',
url: 'testurl',
likelyType: LikelyType.Other,
}
beforeEach(() => {
const api = sessionClient.service(DEFAULT_SERVICE) as SessionServiceClient
rootStore = new RootStoreModel(api)
viewModel = new LinkMetasViewModel(rootStore)
})
afterAll(() => {
jest.clearAllMocks()
})
describe('getLinkMeta', () => {
it('should return link meta if it is cached', async () => {
const url = 'http://example.com'
viewModel.cache.set(url, mockedMeta)
const result = await viewModel.getLinkMeta(url)
expect(getLinkMetaMockSpy).not.toHaveBeenCalled()
expect(result).toEqual(mockedMeta)
})
it('should return link meta if it is not cached', async () => {
getLinkMetaMockSpy.mockResolvedValueOnce(mockedMeta)
const result = await viewModel.getLinkMeta(mockedMeta.url)
expect(getLinkMetaMockSpy).toHaveBeenCalledWith(mockedMeta.url)
expect(result).toEqual(mockedMeta)
})
it('should cache the link meta if it is successfully returned', async () => {
getLinkMetaMockSpy.mockResolvedValueOnce(mockedMeta)
await viewModel.getLinkMeta(mockedMeta.url)
expect(viewModel.cache.get(mockedMeta.url)).toEqual(mockedMeta)
})
it('should not cache the link meta if it fails to return', async () => {
const url = 'http://example.com'
const error = new Error('Failed to fetch link meta')
getLinkMetaMockSpy.mockRejectedValueOnce(error)
try {
await viewModel.getLinkMeta(url)
fail('Error was not thrown')
} catch (e) {
expect(e).toEqual(error)
expect(viewModel.cache.get(url)).toBeUndefined()
}
})
})
})
+153
View File
@@ -0,0 +1,153 @@
import {LogModel} from '../../../src/state/models/log'
describe('LogModel', () => {
let logModel: LogModel
beforeEach(() => {
logModel = new LogModel()
jest.spyOn(console, 'debug')
})
afterAll(() => {
jest.clearAllMocks()
})
it('should call a log method and add a log entry to the entries array', () => {
logModel.debug('Test log')
expect(logModel.entries.length).toEqual(1)
expect(logModel.entries[0]).toEqual({
id: logModel.entries[0].id,
type: 'debug',
summary: 'Test log',
details: undefined,
ts: logModel.entries[0].ts,
})
logModel.warn('Test log')
expect(logModel.entries.length).toEqual(2)
expect(logModel.entries[1]).toEqual({
id: logModel.entries[1].id,
type: 'warn',
summary: 'Test log',
details: undefined,
ts: logModel.entries[1].ts,
})
logModel.error('Test log')
expect(logModel.entries.length).toEqual(3)
expect(logModel.entries[2]).toEqual({
id: logModel.entries[2].id,
type: 'error',
summary: 'Test log',
details: undefined,
ts: logModel.entries[2].ts,
})
})
it('should call the console.debug after calling the debug method', () => {
logModel.debug('Test log')
expect(console.debug).toHaveBeenCalledWith('Test log', '')
})
it('should call the serialize method', () => {
logModel.debug('Test log')
expect(logModel.serialize()).toEqual({
entries: [
{
id: logModel.entries[0].id,
type: 'debug',
summary: 'Test log',
details: undefined,
ts: logModel.entries[0].ts,
},
],
})
})
it('should call the hydrate method with valid properties', () => {
logModel.hydrate({
entries: [
{
id: '123',
type: 'debug',
summary: 'Test log',
details: undefined,
ts: 123,
},
],
})
expect(logModel.entries).toEqual([
{
id: '123',
type: 'debug',
summary: 'Test log',
details: undefined,
ts: 123,
},
])
})
it('should call the hydrate method with invalid properties', () => {
logModel.hydrate({
entries: [
{
id: '123',
type: 'debug',
summary: 'Test log',
details: undefined,
ts: 123,
},
{
summary: 'Invalid entry',
},
],
})
expect(logModel.entries).toEqual([
{
id: '123',
type: 'debug',
summary: 'Test log',
details: undefined,
ts: 123,
},
])
})
it('should stringify the details if it is not a string', () => {
logModel.debug('Test log', {details: 'test'})
expect(logModel.entries[0].details).toEqual('{\n "details": "test"\n}')
})
it('should stringify the details object if it is of a specific error', () => {
class TestError extends Error {
constructor() {
super()
this.name = 'TestError'
}
}
const error = new TestError()
logModel.error('Test error log', error)
expect(logModel.entries[0].details).toEqual('TestError')
class XRPCInvalidResponseErrorMock {
validationError = {toString: () => 'validationError'}
lexiconNsid = 'test'
}
const xrpcInvalidResponseError = new XRPCInvalidResponseErrorMock()
logModel.error('Test error log', xrpcInvalidResponseError)
expect(logModel.entries[1].details).toEqual(
'{\n "validationError": {},\n "lexiconNsid": "test"\n}',
)
class XRPCErrorMock {
status = 'status'
error = 'error'
message = 'message'
}
const xrpcError = new XRPCErrorMock()
logModel.error('Test error log', xrpcError)
expect(logModel.entries[2].details).toEqual(
'{\n "status": "status",\n "error": "error",\n "message": "message"\n}',
)
})
})
+183
View File
@@ -0,0 +1,183 @@
import {RootStoreModel} from '../../../src/state/models/root-store'
import {MeModel} from '../../../src/state/models/me'
import {MembershipsViewModel} from './../../../src/state/models/memberships-view'
import {NotificationsViewModel} from './../../../src/state/models/notifications-view'
import {sessionClient, SessionServiceClient} from '@atproto/api'
import {DEFAULT_SERVICE} from './../../../src/state/index'
describe('MeModel', () => {
let rootStore: RootStoreModel
let meModel: MeModel
beforeEach(() => {
const api = sessionClient.service(DEFAULT_SERVICE) as SessionServiceClient
rootStore = new RootStoreModel(api)
meModel = new MeModel(rootStore)
})
afterAll(() => {
jest.clearAllMocks()
})
it('should clear() correctly', () => {
meModel.did = '123'
meModel.handle = 'handle'
meModel.displayName = 'John Doe'
meModel.description = 'description'
meModel.avatar = 'avatar'
meModel.notificationCount = 1
meModel.clear()
expect(meModel.did).toEqual('')
expect(meModel.handle).toEqual('')
expect(meModel.displayName).toEqual('')
expect(meModel.description).toEqual('')
expect(meModel.avatar).toEqual('')
expect(meModel.notificationCount).toEqual(0)
expect(meModel.memberships).toBeUndefined()
})
it('should hydrate() successfully with valid properties', () => {
meModel.hydrate({
did: '123',
handle: 'handle',
displayName: 'John Doe',
description: 'description',
avatar: 'avatar',
})
expect(meModel.did).toEqual('123')
expect(meModel.handle).toEqual('handle')
expect(meModel.displayName).toEqual('John Doe')
expect(meModel.description).toEqual('description')
expect(meModel.avatar).toEqual('avatar')
})
it('should not hydrate() with invalid properties', () => {
meModel.hydrate({
did: '',
handle: 'handle',
displayName: 'John Doe',
description: 'description',
avatar: 'avatar',
})
expect(meModel.did).toEqual('')
expect(meModel.handle).toEqual('')
expect(meModel.displayName).toEqual('')
expect(meModel.description).toEqual('')
expect(meModel.avatar).toEqual('')
meModel.hydrate({
did: '123',
displayName: 'John Doe',
description: 'description',
avatar: 'avatar',
})
expect(meModel.did).toEqual('')
expect(meModel.handle).toEqual('')
expect(meModel.displayName).toEqual('')
expect(meModel.description).toEqual('')
expect(meModel.avatar).toEqual('')
})
it('should load() successfully', async () => {
jest
.spyOn(rootStore.api.app.bsky.actor, 'getProfile')
.mockImplementationOnce((): Promise<any> => {
return Promise.resolve({
data: {
displayName: 'John Doe',
description: 'description',
avatar: 'avatar',
},
})
})
rootStore.session.data = {
did: '123',
handle: 'handle',
service: 'test service',
accessJwt: 'test token',
refreshJwt: 'test token',
}
await meModel.load()
expect(meModel.did).toEqual('123')
expect(meModel.handle).toEqual('handle')
expect(meModel.displayName).toEqual('John Doe')
expect(meModel.description).toEqual('description')
expect(meModel.avatar).toEqual('avatar')
})
it('should load() successfully without profile data', async () => {
jest
.spyOn(rootStore.api.app.bsky.actor, 'getProfile')
.mockImplementationOnce((): Promise<any> => {
return Promise.resolve({
data: null,
})
})
rootStore.session.data = {
did: '123',
handle: 'handle',
service: 'test service',
accessJwt: 'test token',
refreshJwt: 'test token',
}
await meModel.load()
expect(meModel.did).toEqual('123')
expect(meModel.handle).toEqual('handle')
expect(meModel.displayName).toEqual('')
expect(meModel.description).toEqual('')
expect(meModel.avatar).toEqual('')
})
it('should load() to nothing when no session', async () => {
rootStore.session.data = null
await meModel.load()
expect(meModel.did).toEqual('')
expect(meModel.handle).toEqual('')
expect(meModel.displayName).toEqual('')
expect(meModel.description).toEqual('')
expect(meModel.avatar).toEqual('')
expect(meModel.notificationCount).toEqual(0)
expect(meModel.memberships).toBeUndefined()
})
it('should serialize() key information', () => {
meModel.did = '123'
meModel.handle = 'handle'
meModel.displayName = 'John Doe'
meModel.description = 'description'
meModel.avatar = 'avatar'
expect(meModel.serialize()).toEqual({
did: '123',
handle: 'handle',
displayName: 'John Doe',
description: 'description',
avatar: 'avatar',
})
})
it('should clearNotificationCount() successfully', () => {
meModel.clearNotificationCount()
expect(meModel.notificationCount).toBe(0)
})
it('should update notifs count with fetchStateUpdate()', async () => {
meModel.notifications = {
refresh: jest.fn(),
} as unknown as NotificationsViewModel
jest
.spyOn(rootStore.api.app.bsky.notification, 'getCount')
.mockImplementationOnce((): Promise<any> => {
return Promise.resolve({
data: {
count: 1,
},
})
})
await meModel.fetchStateUpdate()
expect(meModel.notificationCount).toBe(1)
expect(meModel.notifications.refresh).toHaveBeenCalled()
})
})
+154
View File
@@ -0,0 +1,154 @@
import {
NavigationModel,
NavigationTabModel,
} from './../../../src/state/models/navigation'
import * as flags from '../../../src/build-flags'
describe('NavigationModel', () => {
let model: NavigationModel
beforeEach(() => {
model = new NavigationModel()
model.setTitle([0, 0], 'title')
})
afterAll(() => {
jest.clearAllMocks()
})
it('should clear() to the correct base state', async () => {
await model.clear()
expect(model.tabCount).toBe(2)
expect(model.tab).toEqual({
fixedTabPurpose: 0,
history: [
{
id: expect.anything(),
ts: expect.anything(),
url: '/',
},
],
id: expect.anything(),
index: 0,
isNewTab: false,
})
})
it('should call the navigate method', async () => {
const navigateSpy = jest.spyOn(model.tab, 'navigate')
await model.navigate('testurl', 'teststring')
expect(navigateSpy).toHaveBeenCalledWith('testurl', 'teststring')
})
it('should call the refresh method', async () => {
const refreshSpy = jest.spyOn(model.tab, 'refresh')
await model.refresh()
expect(refreshSpy).toHaveBeenCalled()
})
it('should call the isCurrentScreen method', () => {
expect(model.isCurrentScreen(11, 0)).toEqual(false)
})
it('should call the tab getter', () => {
expect(model.tab).toEqual({
fixedTabPurpose: 0,
history: [
{
id: expect.anything(),
ts: expect.anything(),
url: '/',
},
],
id: expect.anything(),
index: 0,
isNewTab: false,
})
})
it('should call the tabCount getter', () => {
expect(model.tabCount).toBe(2)
})
describe('tabs not enabled', () => {
jest.mock('../../../src/build-flags', () => ({
TABS_ENABLED: false,
}))
afterAll(() => {
jest.clearAllMocks()
})
it('should not create new tabs', () => {
// @ts-expect-error
flags.TABS_ENABLED = false
model.newTab('testurl')
expect(model.tab.isNewTab).toBe(false)
expect(model.tabIndex).toBe(0)
})
it('should not change the active tab', () => {
// @ts-expect-error
flags.TABS_ENABLED = false
model.setActiveTab(2)
expect(model.tabIndex).toBe(0)
})
it('should note close tabs', () => {
// @ts-expect-error
flags.TABS_ENABLED = false
model.closeTab(0)
expect(model.tabCount).toBe(2)
})
})
describe('tabs enabled', () => {
jest.mock('../../../src/build-flags', () => ({
TABS_ENABLED: true,
}))
afterAll(() => {
jest.clearAllMocks()
})
it('should create new tabs', () => {
// @ts-expect-error
flags.TABS_ENABLED = true
model.newTab('testurl', 'title')
expect(model.tab.isNewTab).toBe(true)
expect(model.tabIndex).toBe(2)
})
it('should change the current tab', () => {
// @ts-expect-error
flags.TABS_ENABLED = true
model.setActiveTab(0)
expect(model.tabIndex).toBe(0)
})
it('should close tabs', () => {
// @ts-expect-error
flags.TABS_ENABLED = true
model.closeTab(0)
expect(model.tabs).toEqual([
{
fixedTabPurpose: 1,
history: [
{
id: expect.anything(),
ts: expect.anything(),
url: '/notifications',
},
],
id: expect.anything(),
index: 0,
isNewTab: false,
},
])
expect(model.tabIndex).toBe(0)
})
})
})
+46
View File
@@ -0,0 +1,46 @@
import {
OnboardModel,
OnboardStageOrder,
} from '../../../src/state/models/onboard'
describe('OnboardModel', () => {
let onboardModel: OnboardModel
beforeEach(() => {
onboardModel = new OnboardModel()
})
afterAll(() => {
jest.clearAllMocks()
})
it('should start/stop correctly', () => {
onboardModel.start()
expect(onboardModel.isOnboarding).toBe(true)
onboardModel.stop()
expect(onboardModel.isOnboarding).toBe(false)
})
it('should call the next method until it has no more stages', () => {
onboardModel.start()
onboardModel.next()
expect(onboardModel.stage).toBe(OnboardStageOrder[1])
onboardModel.next()
expect(onboardModel.isOnboarding).toBe(false)
expect(onboardModel.stage).toBe(OnboardStageOrder[0])
})
it('serialize and hydrate', () => {
const serialized = onboardModel.serialize()
const newModel = new OnboardModel()
newModel.hydrate(serialized)
expect(newModel).toEqual(onboardModel)
onboardModel.start()
onboardModel.next()
const serialized2 = onboardModel.serialize()
newModel.hydrate(serialized2)
expect(newModel).toEqual(onboardModel)
})
})
+73
View File
@@ -0,0 +1,73 @@
import {RootStoreModel} from '../../../src/state/models/root-store'
import {setupState} from '../../../src/state'
describe('rootStore', () => {
let rootStore: RootStoreModel
beforeAll(() => {
jest.useFakeTimers()
})
beforeEach(async () => {
rootStore = await setupState()
})
afterAll(() => {
jest.clearAllMocks()
})
it('resolveName() handles inputs correctly', () => {
const spyMethod = jest
.spyOn(rootStore.api.com.atproto.handle, 'resolve')
.mockResolvedValue({success: true, headers: {}, data: {did: 'testdid'}})
rootStore.resolveName('teststring')
expect(spyMethod).toHaveBeenCalledWith({handle: 'teststring'})
expect(rootStore.resolveName('')).rejects.toThrow('Invalid handle: ""')
expect(rootStore.resolveName('did:123')).resolves.toReturnWith('did:123')
})
it('should call the clearAll() resets state correctly', () => {
rootStore.clearAll()
expect(rootStore.session.data).toEqual(null)
expect(rootStore.nav.tabs).toEqual([
{
fixedTabPurpose: 0,
history: [
{
id: expect.anything(),
ts: expect.anything(),
url: '/',
},
],
id: expect.anything(),
index: 0,
isNewTab: false,
},
{
fixedTabPurpose: 1,
history: [
{
id: expect.anything(),
ts: expect.anything(),
url: '/notifications',
},
],
id: expect.anything(),
index: 0,
isNewTab: false,
},
])
expect(rootStore.nav.tabIndex).toEqual(0)
expect(rootStore.me.did).toEqual('')
expect(rootStore.me.handle).toEqual('')
expect(rootStore.me.displayName).toEqual('')
expect(rootStore.me.description).toEqual('')
expect(rootStore.me.avatar).toEqual('')
expect(rootStore.me.notificationCount).toEqual(0)
expect(rootStore.me.memberships).toBeUndefined()
})
})
+59
View File
@@ -0,0 +1,59 @@
import {
ConfirmModal,
ImageLightbox,
ShellUiModel,
} from './../../../src/state/models/shell-ui'
describe('ShellUiModel', () => {
let model: ShellUiModel
beforeEach(() => {
model = new ShellUiModel()
})
afterAll(() => {
jest.clearAllMocks()
})
it('should call the openModal & closeModal method', () => {
model.openModal(ConfirmModal)
expect(model.isModalActive).toEqual(true)
expect(model.activeModal).toEqual(ConfirmModal)
model.closeModal()
expect(model.isModalActive).toEqual(false)
expect(model.activeModal).toBeUndefined()
})
it('should call the openLightbox & closeLightbox method', () => {
model.openLightbox(new ImageLightbox('uri'))
expect(model.isLightboxActive).toEqual(true)
expect(model.activeLightbox).toEqual(new ImageLightbox('uri'))
model.closeLightbox()
expect(model.isLightboxActive).toEqual(false)
expect(model.activeLightbox).toBeUndefined()
})
it('should call the openComposer & closeComposer method', () => {
const composer = {
replyTo: {
uri: 'uri',
cid: 'cid',
text: 'text',
author: {
handle: 'handle',
displayName: 'name',
},
},
onPost: jest.fn(),
}
model.openComposer(composer)
expect(model.isComposerActive).toEqual(true)
expect(model.composerOpts).toEqual(composer)
model.closeComposer()
expect(model.isComposerActive).toEqual(false)
expect(model.composerOpts).toBeUndefined()
})
})
@@ -0,0 +1,43 @@
import React from 'react'
import {Autocomplete} from '../../../../src/view/com/composer/Autocomplete'
import {cleanup, fireEvent, render} from '../../../../jest/test-utils'
describe('Autocomplete', () => {
const onSelectMock = jest.fn()
const mockedProps = {
active: true,
items: [
{
handle: 'handle.test',
displayName: 'Test Display',
},
{
handle: 'handle2.test',
displayName: 'Test Display 2',
},
],
onSelect: onSelectMock,
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders a button for each user', async () => {
const {findAllByTestId} = render(<Autocomplete {...mockedProps} />)
const autocompleteButton = await findAllByTestId('autocompleteButton')
expect(autocompleteButton.length).toBe(2)
})
it('triggers onSelect by pressing the button', async () => {
const {findAllByTestId} = render(<Autocomplete {...mockedProps} />)
const autocompleteButton = await findAllByTestId('autocompleteButton')
fireEvent.press(autocompleteButton[0])
expect(onSelectMock).toHaveBeenCalledWith('handle.test')
fireEvent.press(autocompleteButton[1])
expect(onSelectMock).toHaveBeenCalledWith('handle2.test')
})
})
@@ -0,0 +1,117 @@
import React from 'react'
import {ComposePost} from '../../../../src/view/com/composer/ComposePost'
import {cleanup, fireEvent, render, waitFor} from '../../../../jest/test-utils'
import * as apilib from '../../../../src/state/lib/api'
import {
mockedAutocompleteViewStore,
mockedRootStore,
} from '../../../../__mocks__/state-mock'
import Toast from 'react-native-root-toast'
describe('ComposePost', () => {
const mockedProps = {
replyTo: {
uri: 'testUri',
cid: 'testCid',
text: 'testText',
author: {
handle: 'test.handle',
displayName: 'test name',
avatar: '',
},
},
onPost: jest.fn(),
onClose: jest.fn(),
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders post composer', async () => {
const {findByTestId} = render(<ComposePost {...mockedProps} />)
const composePostView = await findByTestId('composePostView')
expect(composePostView).toBeTruthy()
})
it('closes composer', async () => {
const {findByTestId} = render(<ComposePost {...mockedProps} />)
const composerCancelButton = await findByTestId('composerCancelButton')
fireEvent.press(composerCancelButton)
expect(mockedProps.onClose).toHaveBeenCalled()
})
it('changes text and publishes post', async () => {
const postSpy = jest.spyOn(apilib, 'post').mockResolvedValue({
uri: '',
cid: '',
})
const toastSpy = jest.spyOn(Toast, 'show')
const wrapper = render(<ComposePost {...mockedProps} />)
const composerTextInput = await wrapper.findByTestId('composerTextInput')
fireEvent.changeText(composerTextInput, 'testing publish')
const composerPublishButton = await wrapper.findByTestId(
'composerPublishButton',
)
fireEvent.press(composerPublishButton)
expect(postSpy).toHaveBeenCalledWith(
mockedRootStore,
'testing publish',
'testUri',
[],
new Set<string>(),
expect.anything(),
)
// Waits for request to be resolved
await waitFor(() => {
expect(mockedProps.onPost).toHaveBeenCalled()
expect(mockedProps.onClose).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Your reply has been published', {
animation: true,
duration: 3500,
hideOnPress: true,
position: 50,
shadow: true,
})
})
})
it('selects autocomplete item', async () => {
jest
.spyOn(React, 'useMemo')
.mockReturnValueOnce(mockedAutocompleteViewStore)
const {findAllByTestId} = render(<ComposePost {...mockedProps} />)
const autocompleteButton = await findAllByTestId('autocompleteButton')
fireEvent.press(autocompleteButton[0])
expect(mockedAutocompleteViewStore.setActive).toHaveBeenCalledWith(false)
})
it('selects photos', async () => {
const {findByTestId, queryByTestId} = render(
<ComposePost {...mockedProps} />,
)
let photoCarouselPickerView = queryByTestId('photoCarouselPickerView')
expect(photoCarouselPickerView).toBeFalsy()
const composerSelectPhotosButton = await findByTestId(
'composerSelectPhotosButton',
)
fireEvent.press(composerSelectPhotosButton)
photoCarouselPickerView = await findByTestId('photoCarouselPickerView')
expect(photoCarouselPickerView).toBeTruthy()
fireEvent.press(composerSelectPhotosButton)
photoCarouselPickerView = queryByTestId('photoCarouselPickerView')
expect(photoCarouselPickerView).toBeFalsy()
})
})
@@ -0,0 +1,92 @@
import React from 'react'
import {PhotoCarouselPicker} from '../../../../src/view/com/composer/PhotoCarouselPicker'
import {cleanup, fireEvent, render} from '../../../../jest/test-utils'
import {
openCamera,
openCropper,
openPicker,
} from 'react-native-image-crop-picker'
describe('PhotoCarouselPicker', () => {
const mockedProps = {
selectedPhotos: ['mock-uri', 'mock-uri-2'],
onSelectPhotos: jest.fn(),
localPhotos: {
photos: [
{
node: {
image: {
uri: 'mock-uri',
},
},
},
],
},
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders carousel picker', async () => {
const {findByTestId} = render(<PhotoCarouselPicker {...mockedProps} />)
const photoCarouselPickerView = await findByTestId(
'photoCarouselPickerView',
)
expect(photoCarouselPickerView).toBeTruthy()
})
it('triggers openCamera', async () => {
const {findByTestId} = render(<PhotoCarouselPicker {...mockedProps} />)
const openCameraButton = await findByTestId('openCameraButton')
fireEvent.press(openCameraButton)
expect(openCamera).toHaveBeenCalledWith({
compressImageQuality: 1,
cropping: true,
forceJpg: true,
freeStyleCropEnabled: true,
height: 1000,
mediaType: 'photo',
width: 1000,
})
})
it('triggers openCropper', async () => {
const {findByTestId} = render(<PhotoCarouselPicker {...mockedProps} />)
const openSelectPhotoButton = await findByTestId('openSelectPhotoButton')
fireEvent.press(openSelectPhotoButton)
expect(openCropper).toHaveBeenCalledWith({
compressImageQuality: 1,
forceJpg: true,
freeStyleCropEnabled: true,
height: 1000,
mediaType: 'photo',
path: 'mock-uri',
width: 1000,
})
})
it('triggers openPicker', async () => {
const {findByTestId} = render(<PhotoCarouselPicker {...mockedProps} />)
const openGalleryButton = await findByTestId('openGalleryButton')
fireEvent.press(openGalleryButton)
expect(openPicker).toHaveBeenCalledWith({
maxFiles: 2,
mediaType: 'photo',
multiple: true,
})
expect(openCropper).toHaveBeenCalledWith({
compressImageQuality: 1,
forceJpg: true,
freeStyleCropEnabled: true,
height: 1000,
mediaType: 'photo',
path: 'mock-uri',
width: 1000,
})
})
})
@@ -0,0 +1,70 @@
import React from 'react'
import {SelectedPhoto} from '../../../../src/view/com/composer/SelectedPhoto'
import {cleanup, fireEvent, render} from '../../../../jest/test-utils'
describe('SelectedPhoto', () => {
const mockedProps = {
selectedPhotos: ['mock-uri', 'mock-uri-2'],
onSelectPhotos: jest.fn(),
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('has no photos to render', () => {
const {queryByTestId} = render(
<SelectedPhoto selectedPhotos={[]} onSelectPhotos={jest.fn()} />,
)
const selectedPhotosView = queryByTestId('selectedPhotosView')
expect(selectedPhotosView).toBeNull()
const selectedPhotoImage = queryByTestId('selectedPhotoImage')
expect(selectedPhotoImage).toBeNull()
})
it('has 1 photos to render', async () => {
const {findByTestId} = render(
<SelectedPhoto
selectedPhotos={['mock-uri']}
onSelectPhotos={jest.fn()}
/>,
)
const selectedPhotosView = await findByTestId('selectedPhotosView')
expect(selectedPhotosView).toBeTruthy()
const selectedPhotoImage = await findByTestId('selectedPhotoImage')
expect(selectedPhotoImage).toBeTruthy()
// @ts-expect-error
expect(selectedPhotoImage).toHaveStyle({width: 250})
})
it('has 2 photos to render', async () => {
const {findAllByTestId} = render(<SelectedPhoto {...mockedProps} />)
const selectedPhotoImage = await findAllByTestId('selectedPhotoImage')
expect(selectedPhotoImage[0]).toBeTruthy()
// @ts-expect-error
expect(selectedPhotoImage[0]).toHaveStyle({width: 175})
})
it('has 3 photos to render', async () => {
const {findAllByTestId} = render(
<SelectedPhoto
selectedPhotos={['mock-uri', 'mock-uri-2', 'mock-uri-3']}
onSelectPhotos={jest.fn()}
/>,
)
const selectedPhotoImage = await findAllByTestId('selectedPhotoImage')
expect(selectedPhotoImage[0]).toBeTruthy()
// @ts-expect-error
expect(selectedPhotoImage[0]).toHaveStyle({width: 85})
})
it('removes a photo', async () => {
const {findAllByTestId} = render(<SelectedPhoto {...mockedProps} />)
const removePhotoButton = await findAllByTestId('removePhotoButton')
fireEvent.press(removePhotoButton[0])
expect(mockedProps.onSelectPhotos).toHaveBeenCalledWith(['mock-uri-2'])
})
})
@@ -0,0 +1,60 @@
import React from 'react'
import {Keyboard} from 'react-native'
import {CreateAccount} from '../../../../src/view/com/login/CreateAccount'
import {cleanup, fireEvent, render} from '../../../../jest/test-utils'
import {
mockedLogStore,
mockedRootStore,
mockedSessionStore,
mockedShellStore,
} from '../../../../__mocks__/state-mock'
describe('CreateAccount', () => {
const mockedProps = {
onPressBack: jest.fn(),
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders form and creates new account', async () => {
const {findByTestId} = render(<CreateAccount {...mockedProps} />)
const registerEmailInput = await findByTestId('registerEmailInput')
expect(registerEmailInput).toBeTruthy()
fireEvent.changeText(registerEmailInput, 'test@email.com')
const registerHandleInput = await findByTestId('registerHandleInput')
expect(registerHandleInput).toBeTruthy()
fireEvent.changeText(registerHandleInput, 'test.handle')
const registerPasswordInput = await findByTestId('registerPasswordInput')
expect(registerPasswordInput).toBeTruthy()
fireEvent.changeText(registerPasswordInput, 'testpass')
const registerIs13Input = await findByTestId('registerIs13Input')
expect(registerIs13Input).toBeTruthy()
fireEvent.press(registerIs13Input)
const createAccountButton = await findByTestId('createAccountButton')
expect(createAccountButton).toBeTruthy()
fireEvent.press(createAccountButton)
expect(mockedSessionStore.createAccount).toHaveBeenCalled()
})
it('renders and selects service', async () => {
const keyboardSpy = jest.spyOn(Keyboard, 'dismiss')
const {findByTestId} = render(<CreateAccount {...mockedProps} />)
const registerSelectServiceButton = await findByTestId(
'registerSelectServiceButton',
)
expect(registerSelectServiceButton).toBeTruthy()
fireEvent.press(registerSelectServiceButton)
expect(mockedShellStore.openModal).toHaveBeenCalled()
expect(keyboardSpy).toHaveBeenCalled()
})
})
+128
View File
@@ -0,0 +1,128 @@
import React from 'react'
import {Signin} from '../../../../src/view/com/login/Signin'
import {cleanup, fireEvent, render} from '../../../../jest/test-utils'
import {SessionServiceClient, sessionClient as AtpApi} from '@atproto/api'
import {
mockedLogStore,
mockedRootStore,
mockedSessionStore,
mockedShellStore,
} from '../../../../__mocks__/state-mock'
import {Keyboard} from 'react-native'
describe('Signin', () => {
const requestPasswordResetMock = jest.fn()
const resetPasswordMock = jest.fn()
jest.spyOn(AtpApi, 'service').mockReturnValue({
com: {
atproto: {
account: {
requestPasswordReset: requestPasswordResetMock,
resetPassword: resetPasswordMock,
},
},
},
} as unknown as SessionServiceClient)
const mockedProps = {
onPressBack: jest.fn(),
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders logs in form', async () => {
const {findByTestId} = render(<Signin {...mockedProps} />)
const loginFormView = await findByTestId('loginFormView')
expect(loginFormView).toBeTruthy()
const loginUsernameInput = await findByTestId('loginUsernameInput')
expect(loginUsernameInput).toBeTruthy()
fireEvent.changeText(loginUsernameInput, 'testusername')
const loginPasswordInput = await findByTestId('loginPasswordInput')
expect(loginPasswordInput).toBeTruthy()
fireEvent.changeText(loginPasswordInput, 'test pass')
const loginNextButton = await findByTestId('loginNextButton')
expect(loginNextButton).toBeTruthy()
fireEvent.press(loginNextButton)
expect(mockedSessionStore.login).toHaveBeenCalled()
})
it('renders selects service from login form', async () => {
const keyboardSpy = jest.spyOn(Keyboard, 'dismiss')
const {findByTestId} = render(<Signin {...mockedProps} />)
const loginSelectServiceButton = await findByTestId(
'loginSelectServiceButton',
)
expect(loginSelectServiceButton).toBeTruthy()
fireEvent.press(loginSelectServiceButton)
expect(mockedShellStore.openModal).toHaveBeenCalled()
expect(keyboardSpy).toHaveBeenCalled()
})
it('renders new password form', async () => {
const {findByTestId} = render(<Signin {...mockedProps} />)
const forgotPasswordButton = await findByTestId('forgotPasswordButton')
expect(forgotPasswordButton).toBeTruthy()
fireEvent.press(forgotPasswordButton)
const forgotPasswordView = await findByTestId('forgotPasswordView')
expect(forgotPasswordView).toBeTruthy()
const forgotPasswordEmail = await findByTestId('forgotPasswordEmail')
expect(forgotPasswordEmail).toBeTruthy()
fireEvent.changeText(forgotPasswordEmail, 'test@email.com')
const newPasswordButton = await findByTestId('newPasswordButton')
expect(newPasswordButton).toBeTruthy()
fireEvent.press(newPasswordButton)
expect(requestPasswordResetMock).toHaveBeenCalled()
const newPasswordView = await findByTestId('newPasswordView')
expect(newPasswordView).toBeTruthy()
const newPasswordInput = await findByTestId('newPasswordInput')
expect(newPasswordInput).toBeTruthy()
const resetCodeInput = await findByTestId('resetCodeInput')
expect(resetCodeInput).toBeTruthy()
fireEvent.changeText(newPasswordInput, 'test pass')
fireEvent.changeText(resetCodeInput, 'test reset code')
const setNewPasswordButton = await findByTestId('setNewPasswordButton')
expect(setNewPasswordButton).toBeTruthy()
fireEvent.press(setNewPasswordButton)
expect(resetPasswordMock).toHaveBeenCalled()
})
it('renders forgot password form', async () => {
const {findByTestId} = render(<Signin {...mockedProps} />)
const forgotPasswordButton = await findByTestId('forgotPasswordButton')
expect(forgotPasswordButton).toBeTruthy()
fireEvent.press(forgotPasswordButton)
const forgotPasswordSelectServiceButton = await findByTestId(
'forgotPasswordSelectServiceButton',
)
expect(forgotPasswordSelectServiceButton).toBeTruthy()
fireEvent.press(forgotPasswordSelectServiceButton)
expect(mockedShellStore.openModal).toHaveBeenCalled()
})
})
@@ -0,0 +1,109 @@
import React from 'react'
import {cleanup, fireEvent, render} from '../../../../jest/test-utils'
import {ProfileViewModel} from '../../../../src/state/models/profile-view'
import {ProfileHeader} from '../../../../src/view/com/profile/ProfileHeader'
import {
mockedNavigationStore,
mockedProfileStore,
mockedShellStore,
} from '../../../../__mocks__/state-mock'
describe('ProfileHeader', () => {
const mockedProps = {
view: mockedProfileStore,
onRefreshAll: jest.fn(),
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders ErrorMessage on error', async () => {
const {findByTestId} = render(
<ProfileHeader
{...{
view: {
...mockedProfileStore,
hasError: true,
} as ProfileViewModel,
onRefreshAll: jest.fn(),
}}
/>,
)
const profileHeaderHasError = await findByTestId('profileHeaderHasError')
expect(profileHeaderHasError).toBeTruthy()
})
it('presses and opens edit profile', async () => {
const {findByTestId} = render(<ProfileHeader {...mockedProps} />)
const profileHeaderEditProfileButton = await findByTestId(
'profileHeaderEditProfileButton',
)
expect(profileHeaderEditProfileButton).toBeTruthy()
fireEvent.press(profileHeaderEditProfileButton)
expect(mockedShellStore.openModal).toHaveBeenCalled()
})
it('presses and opens followers page', async () => {
const {findByTestId} = render(<ProfileHeader {...mockedProps} />)
const profileHeaderFollowersButton = await findByTestId(
'profileHeaderFollowersButton',
)
expect(profileHeaderFollowersButton).toBeTruthy()
fireEvent.press(profileHeaderFollowersButton)
expect(mockedNavigationStore.navigate).toHaveBeenCalledWith(
'/profile/testhandle/followers',
)
})
it('presses and opens avatar modal', async () => {
const {findByTestId} = render(<ProfileHeader {...mockedProps} />)
const profileHeaderAviButton = await findByTestId('profileHeaderAviButton')
expect(profileHeaderAviButton).toBeTruthy()
fireEvent.press(profileHeaderAviButton)
expect(mockedShellStore.openLightbox).toHaveBeenCalled()
})
it('presses and opens follows page', async () => {
const {findByTestId} = render(<ProfileHeader {...mockedProps} />)
const profileHeaderFollowsButton = await findByTestId(
'profileHeaderFollowsButton',
)
expect(profileHeaderFollowsButton).toBeTruthy()
fireEvent.press(profileHeaderFollowsButton)
expect(mockedNavigationStore.navigate).toHaveBeenCalledWith(
'/profile/testhandle/follows',
)
})
it('toggles following', async () => {
const {findByTestId} = render(
<ProfileHeader
{...{
view: {
...mockedProfileStore,
did: 'test did 2',
} as ProfileViewModel,
onRefreshAll: jest.fn(),
}}
/>,
)
const profileHeaderToggleFollowButton = await findByTestId(
'profileHeaderToggleFollowButton',
)
expect(profileHeaderToggleFollowButton).toBeTruthy()
fireEvent.press(profileHeaderToggleFollowButton)
expect(mockedProps.view.toggleFollowing).toHaveBeenCalled()
})
})
@@ -0,0 +1,17 @@
import {renderHook} from '../../../jest/test-utils'
import {useAnimatedValue} from '../../../src/view/lib/hooks/useAnimatedValue'
describe('useAnimatedValue', () => {
it('creates an Animated.Value with the initial value passed to the hook', () => {
const {result} = renderHook(() => useAnimatedValue(10))
// @ts-expect-error
expect(result.current.__getValue()).toEqual(10)
})
it('returns the same Animated.Value instance on subsequent renders', () => {
const {result, rerender} = renderHook(() => useAnimatedValue(10))
const firstValue = result.current
rerender({})
expect(result.current).toBe(firstValue)
})
})
@@ -0,0 +1,49 @@
import React from 'react'
import {fireEvent, render} from '../../../jest/test-utils'
import {Home} from '../../../src/view/screens/Home'
import {mockedRootStore, mockedShellStore} from '../../../__mocks__/state-mock'
describe('useOnMainScroll', () => {
const mockedProps = {
navIdx: [0, 0] as [number, number],
params: {},
visible: true,
}
it('toggles minimalShellMode to true', () => {
jest.useFakeTimers()
const {getByTestId} = render(<Home {...mockedProps} />)
fireEvent.scroll(getByTestId('homeFeed'), {
nativeEvent: {
contentOffset: {y: 20},
contentSize: {height: 100},
layoutMeasurement: {height: 50},
},
})
expect(mockedRootStore.shell.setMinimalShellMode).toHaveBeenCalledWith(true)
})
it('toggles minimalShellMode to false', () => {
jest.useFakeTimers()
const {getByTestId} = render(<Home {...mockedProps} />, {
...mockedRootStore,
shell: {
...mockedShellStore,
minimalShellMode: true,
},
})
fireEvent.scroll(getByTestId('homeFeed'), {
nativeEvent: {
contentOffset: {y: 0},
contentSize: {height: 100},
layoutMeasurement: {height: 50},
},
})
expect(mockedRootStore.shell.setMinimalShellMode).toHaveBeenCalledWith(
false,
)
})
})
+37
View File
@@ -0,0 +1,37 @@
import React from 'react'
import {Login} from '../../../src/view/screens/Login'
import {cleanup, fireEvent, render} from '../../../jest/test-utils'
describe('Login', () => {
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders initial screen', () => {
const {getByTestId} = render(<Login />)
const signUpScreen = getByTestId('signinOrCreateAccount')
expect(signUpScreen).toBeTruthy()
})
it('renders Signin screen', () => {
const {getByTestId} = render(<Login />)
const signInButton = getByTestId('signInButton')
fireEvent.press(signInButton)
const signInScreen = getByTestId('signIn')
expect(signInScreen).toBeTruthy()
})
it('renders CreateAccount screen', () => {
const {getByTestId} = render(<Login />)
const createAccountButton = getByTestId('createAccountButton')
fireEvent.press(createAccountButton)
const createAccountScreen = getByTestId('createAccount')
expect(createAccountScreen).toBeTruthy()
})
})
+21
View File
@@ -0,0 +1,21 @@
import React from 'react'
import {NotFound} from '../../../src/view/screens/NotFound'
import {cleanup, fireEvent, render} from '../../../jest/test-utils'
import {mockedNavigationStore} from '../../../__mocks__/state-mock'
describe('NotFound', () => {
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('navigates home', async () => {
const navigationSpy = jest.spyOn(mockedNavigationStore, 'navigate')
const {getByTestId} = render(<NotFound />)
const navigateHomeButton = getByTestId('navigateHomeButton')
fireEvent.press(navigateHomeButton)
expect(navigationSpy).toHaveBeenCalledWith('/')
})
})
+30
View File
@@ -0,0 +1,30 @@
import React from 'react'
import {Search} from '../../../src/view/screens/Search'
import {cleanup, fireEvent, render} from '../../../jest/test-utils'
describe('Search', () => {
jest.useFakeTimers()
const mockedProps = {
navIdx: [0, 0] as [number, number],
params: {
name: 'test name',
},
visible: true,
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders with query', async () => {
const {findByTestId} = render(<Search {...mockedProps} />)
const searchTextInput = await findByTestId('searchTextInput')
expect(searchTextInput).toBeTruthy()
fireEvent.changeText(searchTextInput, 'test')
const searchScrollView = await findByTestId('searchScrollView')
expect(searchScrollView).toBeTruthy()
})
})
+60
View File
@@ -0,0 +1,60 @@
import React from 'react'
import {Menu} from '../../../../src/view/shell/mobile/Menu'
import {cleanup, fireEvent, render} from '../../../../jest/test-utils'
import {
mockedNavigationStore,
mockedShellStore,
} from '../../../../__mocks__/state-mock'
describe('Menu', () => {
const onCloseMock = jest.fn()
const mockedProps = {
visible: true,
onClose: onCloseMock,
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders menu', () => {
const {getByTestId} = render(<Menu {...mockedProps} />)
const menuView = getByTestId('menuView')
expect(menuView).toBeTruthy()
})
it('presses profile card button', () => {
const {getByTestId} = render(<Menu {...mockedProps} />)
const profileCardButton = getByTestId('profileCardButton')
fireEvent.press(profileCardButton)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(0, true)
})
it('presses search button', () => {
const {getByTestId} = render(<Menu {...mockedProps} />)
const searchBtn = getByTestId('searchBtn')
fireEvent.press(searchBtn)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(0, true)
expect(mockedNavigationStore.navigate).toHaveBeenCalledWith('/search')
})
it("presses notifications menu item' button", () => {
const {getAllByTestId} = render(<Menu {...mockedProps} />)
const menuItemButton = getAllByTestId('menuItemButton')
fireEvent.press(menuItemButton[1])
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(1, true)
})
})
@@ -0,0 +1,99 @@
import React from 'react'
import {Animated, Share} from 'react-native'
import {TabsSelector} from '../../../../src/view/shell/mobile/TabsSelector'
import {cleanup, fireEvent, render} from '../../../../jest/test-utils'
import {mockedNavigationStore} from '../../../../__mocks__/state-mock'
describe('TabsSelector', () => {
const onCloseMock = jest.fn()
const mockedProps = {
active: true,
tabMenuInterp: new Animated.Value(0),
onClose: onCloseMock,
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders tabs selector', () => {
const {getByTestId} = render(<TabsSelector {...mockedProps} />)
const tabsSelectorView = getByTestId('tabsSelectorView')
expect(tabsSelectorView).toBeTruthy()
})
it('renders nothing if inactive', () => {
const {getByTestId} = render(
<TabsSelector {...{...mockedProps, active: false}} />,
)
const emptyView = getByTestId('emptyView')
expect(emptyView).toBeTruthy()
})
it('presses share button', () => {
const shareSpy = jest.spyOn(Share, 'share')
const {getByTestId} = render(<TabsSelector {...mockedProps} />)
const shareButton = getByTestId('shareButton')
fireEvent.press(shareButton)
expect(onCloseMock).toHaveBeenCalled()
expect(shareSpy).toHaveBeenCalledWith({url: 'https://bsky.app/'})
})
it('presses clone button', () => {
const {getByTestId} = render(<TabsSelector {...mockedProps} />)
const cloneButton = getByTestId('cloneButton')
fireEvent.press(cloneButton)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.newTab).toHaveBeenCalled()
})
it('presses new tab button', () => {
const {getByTestId} = render(<TabsSelector {...mockedProps} />)
const newTabButton = getByTestId('newTabButton')
fireEvent.press(newTabButton)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.newTab).toHaveBeenCalledWith('/')
})
it('presses change tab button', () => {
const {getAllByTestId} = render(<TabsSelector {...mockedProps} />)
const changeTabButton = getAllByTestId('changeTabButton')
fireEvent.press(changeTabButton[0])
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.newTab).toHaveBeenCalledWith('/')
})
it('presses close tab button', () => {
const {getAllByTestId} = render(<TabsSelector {...mockedProps} />)
const closeTabButton = getAllByTestId('closeTabButton')
fireEvent.press(closeTabButton[0])
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.setActiveTab).toHaveBeenCalledWith(0)
})
it('presses swipes to close the tab', () => {
const {getByTestId} = render(<TabsSelector {...mockedProps} />)
const tabsSwipable = getByTestId('tabsSwipable')
fireEvent(tabsSwipable, 'swipeableRightOpen')
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.setActiveTab).toHaveBeenCalledWith(0)
})
})
+2
View File
@@ -267,6 +267,8 @@ dependencies {
implementation "com.facebook.react:react-native:+" // From node_modules
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
implementation project(':rn-fetch-blob')
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
exclude group:'com.facebook.fbjni'
+3 -1
View File
@@ -2,6 +2,8 @@
package="xyz.blueskyweb.app">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:name=".MainApplication"
@@ -15,7 +17,7 @@
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:windowSoftInputMode="adjustPan"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
+4 -3
View File
@@ -4,10 +4,10 @@ import org.apache.tools.ant.taskdefs.condition.Os
buildscript {
ext {
buildToolsVersion = "31.0.0"
buildToolsVersion = "33.0.0"
minSdkVersion = 21
compileSdkVersion = 31
targetSdkVersion = 31
compileSdkVersion = 33
targetSdkVersion = 33
if (System.properties['os.arch'] == "aarch64") {
// For M1 Users we need to use the NDK 24 which added support for aarch64
@@ -49,5 +49,6 @@ allprojects {
}
google()
maven { url 'https://www.jitpack.io' }
maven { url 'https://maven.google.com' }
}
}
+2 -1
View File
@@ -2,7 +2,8 @@ rootProject.name = 'app'
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
include ':app'
includeBuild('../node_modules/react-native-gradle-plugin')
include ':rn-fetch-blob'
project(':rn-fetch-blob').projectDir = new File(rootProject.projectDir, '../node_modules/rn-fetch-blob/android')
if (settings.hasProperty("newArchEnabled") && settings.newArchEnabled == "true") {
include(":ReactAndroid")
project(":ReactAndroid").projectDir = file('../node_modules/react-native/ReactAndroid')
+32 -14
View File
@@ -220,9 +220,11 @@ PODS:
- React-jsinspector (0.68.2)
- React-logger (0.68.2):
- glog
- react-native-cameraroll (5.1.0):
- react-native-cameraroll (5.2.0):
- React-Core
- react-native-pager-view (6.0.2):
- react-native-image-resizer (3.0.4):
- React-Core
- react-native-pager-view (6.1.2):
- React-Core
- react-native-safe-area-context (4.4.1):
- RCT-Folly
@@ -299,11 +301,13 @@ PODS:
- React-jsi (= 0.68.2)
- React-logger (= 0.68.2)
- React-perflogger (= 0.68.2)
- RNCAsyncStorage (1.17.10):
- rn-fetch-blob (0.12.0):
- React-Core
- RNCAsyncStorage (1.17.11):
- React-Core
- RNCClipboard (1.11.1):
- React-Core
- RNGestureHandler (2.6.1):
- RNGestureHandler (2.8.0):
- React-Core
- RNImageCropPicker (0.38.1):
- React-Core
@@ -316,7 +320,9 @@ PODS:
- TOCropViewController
- RNInAppBrowser (3.7.0):
- React-Core
- RNReanimated (2.10.0):
- RNReactNativeHapticFeedback (1.14.0):
- React-Core
- RNReanimated (2.13.0):
- DoubleConversion
- FBLazyVector
- FBReactNativeSpec
@@ -343,10 +349,10 @@ PODS:
- React-RCTText
- ReactCommon/turbomodule/core
- Yoga
- RNScreens (3.17.0):
- RNScreens (3.18.2):
- React-Core
- React-RCTImage
- RNSVG (12.4.4):
- RNSVG (12.5.0):
- React-Core
- TOCropViewController (2.6.1)
- Yoga (1.14.0)
@@ -374,6 +380,7 @@ DEPENDENCIES:
- 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-image-resizer (from `../node_modules/@bam.tech/react-native-image-resizer`)"
- 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`)
@@ -390,11 +397,13 @@ DEPENDENCIES:
- React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
- React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- rn-fetch-blob (from `../node_modules/rn-fetch-blob`)
- "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`)
- RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
- RNSVG (from `../node_modules/react-native-svg`)
@@ -446,6 +455,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/logger"
react-native-cameraroll:
:path: "../node_modules/@react-native-camera-roll/camera-roll"
react-native-image-resizer:
:path: "../node_modules/@bam.tech/react-native-image-resizer"
react-native-pager-view:
:path: "../node_modules/react-native-pager-view"
react-native-safe-area-context:
@@ -478,6 +489,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/runtimeexecutor"
ReactCommon:
:path: "../node_modules/react-native/ReactCommon"
rn-fetch-blob:
:path: "../node_modules/rn-fetch-blob"
RNCAsyncStorage:
:path: "../node_modules/@react-native-async-storage/async-storage"
RNCClipboard:
@@ -488,6 +501,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-image-crop-picker"
RNInAppBrowser:
:path: "../node_modules/react-native-inappbrowser-reborn"
RNReactNativeHapticFeedback:
:path: "../node_modules/react-native-haptic-feedback"
RNReanimated:
:path: "../node_modules/react-native-reanimated"
RNScreens:
@@ -518,8 +533,9 @@ SPEC CHECKSUMS:
React-jsiexecutor: b7b553412f2ec768fe6c8f27cd6bafdb9d8719e6
React-jsinspector: c5989c77cb89ae6a69561095a61cce56a44ae8e8
React-logger: a0833912d93b36b791b7a521672d8ee89107aff1
react-native-cameraroll: a40b082318eb1ecd0336a2f29d9f74b7f2c8cae8
react-native-pager-view: 592421df0259bf7a7a4fe85b74c24f3f39905605
react-native-cameraroll: 0ff04cc4e0ff5f19a94ff4313e5c8bc4503cd86d
react-native-image-resizer: 794abf75ec13ed1f0dbb1f134e27504ea65e9e66
react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43
react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f
@@ -535,14 +551,16 @@ SPEC CHECKSUMS:
React-RCTVibration: 79040b92bfa9c3c2d2cb4f57e981164ec7ab9374
React-runtimeexecutor: b960b687d2dfef0d3761fbb187e01812ebab8b23
ReactCommon: 095366164a276d91ea704ce53cb03825c487a3f2
RNCAsyncStorage: 0c357f3156fcb16c8589ede67cc036330b6698ca
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
RNGestureHandler: 28ad20bf02257791f7f137b31beef34b9549f54b
RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
RNReanimated: 5bdcbcc3a72aedeee7bb099604262403aa75a1e5
RNScreens: 0df01424e9e0ed7827200d6ed1087ddd06c493f9
RNSVG: ecd661f380a07ba690c9c5929c475a44f432d674
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
RNReanimated: b21b362b4b8ca921932e8b1718e88cf3a36f157e
RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
RNSVG: 6adc5c52d2488a476248413064b7f2832e639057
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
Yoga: 99652481fcd320aefa4a7ef90095b95acd181952
+34 -34
View File
@@ -11,9 +11,9 @@
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 */; };
56F0250941FE75DA8CF8094A /* libPods-app-appTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F5D2424078D205E2F98F0707 /* libPods-app-appTests.a */; };
5698CA584FD738B2091BD18F /* libPods-app-appTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C0C7BDE7769B84011D3747DC /* libPods-app-appTests.a */; };
67BF1AE6AABFC881715B2D6A /* libPods-app.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8789F612EDA2C48C6064ADD6 /* libPods-app.a */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
BFADB63545C91424F7C8293A /* libPods-app.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9C668306DD1C0A63684EDCB0 /* libPods-app.a */; };
E4BBD590292C1F5200296224 /* app.entitlements in Resources */ = {isa = PBXBuildFile; fileRef = E4437C9E28581FA7006DA9E7 /* app.entitlements */; };
/* End PBXBuildFile section */
@@ -37,15 +37,15 @@
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>"; };
3D3D4D80CD734363E3EADE03 /* 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>"; };
65D2EDDD9D88DA37994C7743 /* 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>"; };
53DBA218C184B95B107AC33E /* 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>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = app/LaunchScreen.storyboard; sourceTree = "<group>"; };
9C668306DD1C0A63684EDCB0 /* libPods-app.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app.a"; sourceTree = BUILT_PRODUCTS_DIR; };
A79BD0E1A3F5B868AD4F5D68 /* 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>"; };
AA0AD1B60CF7C90FFDB948B9 /* 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>"; };
8789F612EDA2C48C6064ADD6 /* libPods-app.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app.a"; sourceTree = BUILT_PRODUCTS_DIR; };
8BB4EDB104E125B8A1913E74 /* 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>"; };
A8E093A0B5DA947150924A68 /* 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>"; };
C01FB6762BC17DADC0319338 /* 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>"; };
C0C7BDE7769B84011D3747DC /* libPods-app-appTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app-appTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
E4437C9E28581FA7006DA9E7 /* app.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = app.entitlements; path = app/app.entitlements; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F5D2424078D205E2F98F0707 /* libPods-app-appTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app-appTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -53,7 +53,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
56F0250941FE75DA8CF8094A /* libPods-app-appTests.a in Frameworks */,
5698CA584FD738B2091BD18F /* libPods-app-appTests.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -61,7 +61,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BFADB63545C91424F7C8293A /* libPods-app.a in Frameworks */,
67BF1AE6AABFC881715B2D6A /* libPods-app.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -103,8 +103,8 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
9C668306DD1C0A63684EDCB0 /* libPods-app.a */,
F5D2424078D205E2F98F0707 /* libPods-app-appTests.a */,
8789F612EDA2C48C6064ADD6 /* libPods-app.a */,
C0C7BDE7769B84011D3747DC /* libPods-app-appTests.a */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -143,10 +143,10 @@
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup;
children = (
A79BD0E1A3F5B868AD4F5D68 /* Pods-app.debug.xcconfig */,
3D3D4D80CD734363E3EADE03 /* Pods-app.release.xcconfig */,
65D2EDDD9D88DA37994C7743 /* Pods-app-appTests.debug.xcconfig */,
AA0AD1B60CF7C90FFDB948B9 /* Pods-app-appTests.release.xcconfig */,
8BB4EDB104E125B8A1913E74 /* Pods-app.debug.xcconfig */,
53DBA218C184B95B107AC33E /* Pods-app.release.xcconfig */,
C01FB6762BC17DADC0319338 /* Pods-app-appTests.debug.xcconfig */,
A8E093A0B5DA947150924A68 /* Pods-app-appTests.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -158,11 +158,11 @@
isa = PBXNativeTarget;
buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "appTests" */;
buildPhases = (
A8646FFAA087C80271BB2CC6 /* [CP] Check Pods Manifest.lock */,
A6849EFFDCB214D4F4EAE972 /* [CP] Check Pods Manifest.lock */,
00E356EA1AD99517003FC87E /* Sources */,
00E356EB1AD99517003FC87E /* Frameworks */,
00E356EC1AD99517003FC87E /* Resources */,
8DC349844744C81F79F94A1E /* [CP] Copy Pods Resources */,
DFA0C2B14E2F369C4B2337CE /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -178,13 +178,13 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "app" */;
buildPhases = (
AFA1F7779F8B1AF36C3A7914 /* [CP] Check Pods Manifest.lock */,
DDF15D430A078CE70E577FDA /* [CP] Check Pods Manifest.lock */,
FD10A7F022414F080027D42C /* Start Packager */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
EEF3465D9B31EDDCEB2438EA /* [CP] Copy Pods Resources */,
45E56D79C207C80AF89FBAA2 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -266,24 +266,24 @@
shellPath = /bin/sh;
shellScript = "set -e\n\nexport NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh\n";
};
8DC349844744C81F79F94A1E /* [CP] Copy Pods Resources */ = {
45E56D79C207C80AF89FBAA2 /* [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",
"${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-appTests/Pods-app-appTests-resources-${CONFIGURATION}-output-files.xcfilelist",
"${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-appTests/Pods-app-appTests-resources.sh\"\n";
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources.sh\"\n";
showEnvVarsInLog = 0;
};
A8646FFAA087C80271BB2CC6 /* [CP] Check Pods Manifest.lock */ = {
A6849EFFDCB214D4F4EAE972 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -305,7 +305,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;
};
AFA1F7779F8B1AF36C3A7914 /* [CP] Check Pods Manifest.lock */ = {
DDF15D430A078CE70E577FDA /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -327,21 +327,21 @@
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;
};
EEF3465D9B31EDDCEB2438EA /* [CP] Copy Pods Resources */ = {
DFA0C2B14E2F369C4B2337CE /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources-${CONFIGURATION}-input-files.xcfilelist",
"${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/Pods-app-resources-${CONFIGURATION}-output-files.xcfilelist",
"${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/Pods-app-resources.sh\"\n";
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources.sh\"\n";
showEnvVarsInLog = 0;
};
FD10A7F022414F080027D42C /* Start Packager */ = {
@@ -396,7 +396,7 @@
/* Begin XCBuildConfiguration section */
00E356F61AD99517003FC87E /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 65D2EDDD9D88DA37994C7743 /* Pods-app-appTests.debug.xcconfig */;
baseConfigurationReference = C01FB6762BC17DADC0319338 /* Pods-app-appTests.debug.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
@@ -424,7 +424,7 @@
};
00E356F71AD99517003FC87E /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AA0AD1B60CF7C90FFDB948B9 /* Pods-app-appTests.release.xcconfig */;
baseConfigurationReference = A8E093A0B5DA947150924A68 /* Pods-app-appTests.release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
@@ -449,7 +449,7 @@
};
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = A79BD0E1A3F5B868AD4F5D68 /* Pods-app.debug.xcconfig */;
baseConfigurationReference = 8BB4EDB104E125B8A1913E74 /* Pods-app.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
@@ -479,7 +479,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 3D3D4D80CD734363E3EADE03 /* Pods-app.release.xcconfig */;
baseConfigurationReference = 53DBA218C184B95B107AC33E /* Pods-app.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
-3
View File
@@ -1,3 +0,0 @@
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
)
+53
View File
@@ -0,0 +1,53 @@
import 'react-native-gesture-handler/jestSetup'
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
)
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter')
// Silence the warning: Animated: `useNativeDriver` is not supported
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper')
require('react-native-reanimated/lib/reanimated2/jestUtils').setUpTests()
jest.mock('@fortawesome/react-native-fontawesome', () => ({
FontAwesomeIcon: '',
}))
jest.mock('react-native-safe-area-context', () => {
const inset = {top: 0, right: 0, bottom: 0, left: 0}
return {
SafeAreaProvider: jest.fn().mockImplementation(({children}) => children),
SafeAreaConsumer: jest
.fn()
.mockImplementation(({children}) => children(inset)),
useSafeAreaInsets: jest.fn().mockImplementation(() => inset),
}
})
jest.mock('@gorhom/bottom-sheet', () => {
const react = require('react-native')
return {
__esModule: true,
default: react.View,
namedExport: {
...require('react-native-reanimated/mock'),
...jest.requireActual('@gorhom/bottom-sheet'),
BottomSheetFlatList: react.FlatList,
},
}
})
jest.mock('rn-fetch-blob', () => ({
config: jest.fn().mockReturnThis(),
cancel: jest.fn(),
fetch: jest.fn(),
}))
jest.mock('@bam.tech/react-native-image-resizer', () => ({
createResizedImage: jest.fn(),
}))
import {View as mockedView} from 'react-native'
jest.mock('react-native-tab-view', () => ({
...jest.requireActual('react-native-tab-view'),
TabView: mockedView,
}))
+30
View File
@@ -0,0 +1,30 @@
import React from 'react'
import {render} from '@testing-library/react-native'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {RootSiblingParent} from 'react-native-root-siblings'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {RootStoreProvider} from '../src/state'
import {mockedRootStore} from '../__mocks__/state-mock'
const customRender = (ui: any, storeMock?: any) =>
render(
// eslint-disable-next-line react-native/no-inline-styles
<GestureHandlerRootView style={{flex: 1}}>
<RootSiblingParent>
<RootStoreProvider
value={
storeMock != null
? {...mockedRootStore, ...storeMock}
: mockedRootStore
}>
<SafeAreaProvider>{ui}</SafeAreaProvider>
</RootStoreProvider>
</RootSiblingParent>
</GestureHandlerRootView>,
)
// re-export everything
export * from '@testing-library/react-native'
// override render method
export {customRender as render}
+22 -2
View File
@@ -8,10 +8,14 @@
"web": "react-scripts start",
"start": "react-native start",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
"test": "jest",
"test": "jest --coverage",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"dependencies": {
"@atproto/api": "^0.0.6",
"@atproto/lexicon": "^0.0.4",
"@atproto/xrpc": "^0.0.3",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
@@ -35,6 +39,7 @@
"react-native": "0.68.2",
"react-native-appstate-hook": "^1.0.6",
"react-native-gesture-handler": "^2.5.0",
"react-native-haptic-feedback": "^1.14.0",
"react-native-image-crop-picker": "^0.38.1",
"react-native-inappbrowser-reborn": "^3.6.3",
"react-native-linear-gradient": "^2.6.2",
@@ -51,12 +56,15 @@
"react-native-url-polyfill": "^1.3.0",
"react-native-version-number": "^0.3.6",
"react-native-web": "^0.17.7",
"rn-fetch-blob": "^0.12.0",
"tlds": "^1.234.0"
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@babel/runtime": "^7.12.5",
"@react-native-community/eslint-config": "^2.0.0",
"@testing-library/jest-native": "^5.3.3",
"@testing-library/react-native": "^11.5.0",
"@types/he": "^1.1.2",
"@types/jest": "^26.0.23",
"@types/lodash.chunk": "^4.2.7",
@@ -81,7 +89,11 @@
"jest": {
"preset": "react-native",
"setupFiles": [
"./jest.js"
"./jest/jestSetup.js",
"./node_modules/react-native-gesture-handler/jestSetup.js"
],
"setupFilesAfterEnv": [
"@testing-library/jest-native/extend-expect"
],
"moduleFileExtensions": [
"ts",
@@ -93,6 +105,14 @@
],
"transformIgnorePatterns": [
"node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|rollbar-react-native|@fortawesome|@react-native|@react-navigation)"
],
"coveragePathIgnorePatterns": [
"<rootDir>/node_modules/",
"<rootDir>/src/platform",
"<rootDir>/src/third-party",
"<rootDir>/src/view/com/util",
"<rootDir>/src/state/lib",
"<rootDir>/__tests__/test-utils.js"
]
},
"browserslist": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

+9 -5
View File
@@ -5,11 +5,13 @@ import {RootSiblingParent} from 'react-native-root-siblings'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import SplashScreen from 'react-native-splash-screen'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {observer} from 'mobx-react-lite'
import {ThemeProvider} from './view/lib/ThemeContext'
import * as view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {MobileShell} from './view/shell/mobile'
function App() {
const App = observer(() => {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
@@ -40,13 +42,15 @@ function App() {
<GestureHandlerRootView style={{flex: 1}}>
<RootSiblingParent>
<RootStoreProvider value={rootStore}>
<SafeAreaProvider>
<MobileShell />
</SafeAreaProvider>
<ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}>
<SafeAreaProvider>
<MobileShell />
</SafeAreaProvider>
</ThemeProvider>
</RootStoreProvider>
</RootSiblingParent>
</GestureHandlerRootView>
)
}
})
export default App
-1
View File
@@ -1,3 +1,2 @@
export const LOGIN_INCLUDE_DEV_SERVERS = true
export const TABS_ENABLED = false
export const IMAGES_ENABLED = false
+6
View File
@@ -0,0 +1,6 @@
export function choose<U, T extends Record<string, U>>(
value: keyof T,
choices: T,
): U {
return choices[value]
}
+114
View File
@@ -0,0 +1,114 @@
import RNFetchBlob from 'rn-fetch-blob'
import ImageResizer from '@bam.tech/react-native-image-resizer'
export interface DownloadAndResizeOpts {
uri: string
width: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxSize: number
timeout: number
}
export interface Image {
path: string
mime: string
size: number
width: number
height: number
}
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
let appendExt
try {
const urip = new URL(opts.uri)
const ext = urip.pathname.split('.').pop()
if (ext === 'jpg' || ext === 'jpeg') {
appendExt = 'jpeg'
} else if (ext === 'png') {
appendExt = 'png'
} else {
return
}
} catch (e: any) {
console.error('Invalid URI', opts.uri, e)
return
}
let downloadRes
try {
const downloadResPromise = RNFetchBlob.config({
fileCache: true,
appendExt,
}).fetch('GET', opts.uri)
const to1 = setTimeout(() => downloadResPromise.cancel(), opts.timeout)
downloadRes = await downloadResPromise
clearTimeout(to1)
let localUri = downloadRes.path()
if (!localUri.startsWith('file://')) {
localUri = `file://${localUri}`
}
return await resize(localUri, opts)
} finally {
if (downloadRes) {
downloadRes.flush()
}
}
}
export interface ResizeOpts {
width: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxSize: number
}
export async function resize(
localUri: string,
opts: ResizeOpts,
): Promise<Image> {
for (let i = 0; i < 9; i++) {
const quality = 1.0 - i / 10
const resizeRes = await ImageResizer.createResizedImage(
localUri,
opts.width,
opts.height,
'JPEG',
quality,
undefined,
undefined,
undefined,
{mode: opts.mode},
)
if (resizeRes.size < opts.maxSize) {
return {
path: resizeRes.path,
mime: 'image/jpeg',
size: resizeRes.size,
width: resizeRes.width,
height: resizeRes.height,
}
}
}
throw new Error(
`This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`,
)
}
export async function compressIfNeeded(
img: Image,
maxSize: number,
): Promise<Image> {
const origUri = `file://${img.path}`
if (img.size < maxSize) {
return img
}
return await resize(origUri, {
width: img.width,
height: img.height,
mode: 'stretch',
maxSize,
})
}
+14 -4
View File
@@ -22,9 +22,13 @@ export interface LinkMeta {
url: string
title?: string
description?: string
image?: string
}
export async function getLinkMeta(url: string): Promise<LinkMeta> {
export async function getLinkMeta(
url: string,
timeout = 5e3,
): Promise<LinkMeta> {
if (isBskyAppUrl(url)) {
// TODO this could be better
url = convertBskyAppUrlIfNeeded(url)
@@ -57,17 +61,23 @@ export async function getLinkMeta(url: string): Promise<LinkMeta> {
}
try {
const httpRes = await fetch(url)
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), timeout || 5e3)
const httpRes = await fetch(url, {
headers: {accept: 'text/html'},
signal: controller.signal,
})
const httpResBody = await httpRes.text()
clearTimeout(to)
const httpResMeta = extractHtmlMeta(httpResBody)
meta.title = httpResMeta.title ? he.decode(httpResMeta.title) : undefined
meta.description = httpResMeta.description
? he.decode(httpResMeta.description)
: undefined
// TODO meta.image = httpResMeta.image
meta.image = httpResMeta.image
} catch (e) {
// failed
console.log(e)
console.error(e)
meta.error = 'Failed to fetch link'
}
+27 -26
View File
@@ -1,5 +1,6 @@
import {AtUri} from '../third-party/uri'
import {Entity} from '../third-party/api/src/client/types/app/bsky/feed/post'
import {AppBskyFeedPost} from '@atproto/api'
type Entity = AppBskyFeedPost.Entity
import {PROD_SERVICE} from '../state'
import {isNetworkError} from './errors'
import TLDs from 'tlds'
@@ -22,7 +23,7 @@ export function makeRecordUri(
collection: string,
rkey: string,
) {
const urip = new AtUri(`at://host/`)
const urip = new AtUri('at://host/')
urip.host = didOrName
urip.collection = collection
urip.rkey = rkey
@@ -62,7 +63,9 @@ export function ago(date: number | string | Date): string {
export function isValidDomain(str: string): boolean {
return !!TLDs.find(tld => {
let i = str.lastIndexOf(tld)
if (i === -1) return false
if (i === -1) {
return false
}
return str.charAt(i - 1) === '.' && i === str.length - tld.length
})
}
@@ -75,27 +78,25 @@ export function extractEntities(
let ents: Entity[] = []
{
// mentions
const re = /(^|\s|\()(@)([a-zA-Z0-9\.-]+)(\b)/dg
const re = /(^|\s|\()(@)([a-zA-Z0-9\.-]+)(\b)/g
while ((match = re.exec(text))) {
if (knownHandles && !knownHandles.has(match[3])) {
continue // not a known handle
} else if (!match[3].includes('.')) {
continue // probably not a handle
}
const start = text.indexOf(match[3], match.index) - 1
ents.push({
type: 'mention',
value: match[3],
index: {
start: match.indices[2][0], // skip the (^|\s) but include the '@'
end: match.indices[3][1],
},
index: {start, end: start + match[3].length + 1},
})
}
}
{
// links
const re =
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/dgm
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gm
while ((match = re.exec(text))) {
let value = match[2]
if (!value.startsWith('http')) {
@@ -105,20 +106,16 @@ export function extractEntities(
}
value = `https://${value}`
}
const index = {
start: match.indices[2][0], // skip the (^|\s)
end: match.indices[2][1],
const start = text.indexOf(match[2], match.index)
const index = {start, end: start + match[2].length}
// strip ending puncuation
if (/[.,;!?]$/.test(value)) {
value = value.slice(0, -1)
index.end--
}
{
// 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--
}
if (/[)]$/.test(value) && !value.includes('(')) {
value = value.slice(0, -1)
index.end--
}
ents.push({
type: 'link',
@@ -188,8 +185,8 @@ export function makeValidHandle(str: string): string {
}
export function createFullHandle(name: string, domain: string): string {
name = name.replace(/[\.]+$/, '')
domain = domain.replace(/^[\.]+/, '')
name = (name || '').replace(/[\.]+$/, '')
domain = (domain || '').replace(/^[\.]+/, '')
return `${name}.${domain}`
}
@@ -201,7 +198,11 @@ export function enforceLen(str: string, len: number): string {
return str
}
export function cleanError(str: string): string {
export function cleanError(str: any): string {
if (!str) return str
if (typeof str !== 'string') {
str = str.toString()
}
if (isNetworkError(str)) {
return 'Unable to connect. Please check your internet connection and try again.'
}
@@ -259,7 +260,7 @@ export function convertBskyAppUrlIfNeeded(url: string): string {
const urlp = new URL(url)
return urlp.pathname
} catch (e) {
console.log('Unexpected error in convertBskyAppUrlIfNeeded()', e)
console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
}
}
return url
+10 -9
View File
@@ -1,11 +1,12 @@
import {autorun} from 'mobx'
import {sessionClient as AtpApi} from '../third-party/api'
import type {SessionServiceClient} from '../third-party/api/src/index'
import {Platform} from 'react-native'
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
import {RootStoreModel} from './models/root-store'
import * as libapi from './lib/api'
import * as storage from './lib/storage'
export const LOCAL_DEV_SERVICE = 'http://localhost:2583'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'ios' ? 'http://localhost:2583' : 'http://10.0.2.2:2583'
export const STAGING_SERVICE = 'https://pds.staging.bsky.dev'
export const PROD_SERVICE = 'https://bsky.social'
export const DEFAULT_SERVICE = PROD_SERVICE
@@ -22,20 +23,20 @@ export async function setupState() {
rootStore = new RootStoreModel(api)
try {
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
rootStore.hydrate(data)
} catch (e) {
console.error('Failed to load state from storage', e)
} catch (e: any) {
rootStore.log.error('Failed to load state from storage', e)
}
console.log('Initial hydrate', rootStore.me)
rootStore.session
.connect()
.then(() => {
console.log('Session connected', rootStore.me)
rootStore.log.debug('Session connected')
return rootStore.fetchStateUpdate()
})
.catch(e => {
console.log('Failed initial connect', e)
.catch((e: any) => {
rootStore.log.warn('Failed initial connect', e)
})
// @ts-ignore .on() is correct -prf
api.sessionManager.on('session', () => {
+133 -70
View File
@@ -4,13 +4,18 @@
*/
// import {ReactNativeStore} from './auth'
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 {
sessionClient as AtpApi,
APP_BSKY_GRAPH,
AppBskyEmbedImages,
AppBskyEmbedExternal,
} from '@atproto/api'
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'
import {isNetworkError} from '../../lib/errors'
import {downloadAndResize} from '../../lib/images'
import {getLikelyType, LikelyType, getLinkMeta} from '../../lib/link-meta'
const TIMEOUT = 10e3 // 10s
@@ -21,12 +26,113 @@ export function doPolyfill() {
export async function post(
store: RootStoreModel,
text: string,
replyTo?: Post.ReplyRef,
replyTo?: string,
images?: string[],
knownHandles?: Set<string>,
onStateChange?: (state: string) => void,
) {
let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined
let reply
onStateChange?.('Processing...')
const entities = extractEntities(text, knownHandles)
if (entities) {
for (const ent of entities) {
if (ent.type === 'mention') {
const prof = await store.profiles.getProfile(ent.value)
ent.value = prof.data.did
}
}
}
if (images?.length) {
embed = {
$type: 'app.bsky.embed.images',
images: [],
} as AppBskyEmbedImages.Main
let i = 1
for (const image of images) {
onStateChange?.(`Uploading image #${i++}...`)
const res = await store.api.com.atproto.blob.upload(
image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding: 'image/jpeg'},
)
embed.images.push({
image: {
cid: res.data.cid,
mimeType: 'image/jpeg',
},
alt: '', // TODO supply alt text
})
}
}
if (!embed && entities) {
const link = entities.find(
ent =>
ent.type === 'link' &&
getLikelyType(ent.value || '') === LikelyType.HTML,
)
if (link) {
try {
onStateChange?.(`Fetching link metadata...`)
let thumb
const linkMeta = await getLinkMeta(link.value)
if (linkMeta.image) {
onStateChange?.(`Downloading link thumbnail...`)
const thumbLocal = await downloadAndResize({
uri: linkMeta.image,
width: 250,
height: 250,
mode: 'contain',
maxSize: 100000,
timeout: 15e3,
}).catch(() => undefined)
if (thumbLocal) {
onStateChange?.(`Uploading link thumbnail...`)
let encoding
if (thumbLocal.uri.endsWith('.png')) {
encoding = 'image/png'
} else if (
thumbLocal.uri.endsWith('.jpeg') ||
thumbLocal.uri.endsWith('.jpg')
) {
encoding = 'image/jpeg'
} else {
store.log.warn(
'Unexpected image format for thumbnail, skipping',
thumbLocal.uri,
)
}
if (encoding) {
const thumbUploadRes = await store.api.com.atproto.blob.upload(
thumbLocal.uri, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding},
)
thumb = {
cid: thumbUploadRes.data.cid,
mimeType: encoding,
}
}
}
}
embed = {
$type: 'app.bsky.embed.external',
external: {
uri: link.value,
title: linkMeta.title || linkMeta.url,
description: linkMeta.description || '',
thumb,
},
} as AppBskyEmbedExternal.Main
} catch (e: any) {
store.log.warn(`Failed to fetch link meta for ${link.value}`, e)
}
}
}
if (replyTo) {
const replyToUrip = new AtUri(replyTo.uri)
const replyToUrip = new AtUri(replyTo)
const parentPost = await store.api.app.bsky.feed.post.get({
user: replyToUrip.host,
rkey: replyToUrip.rkey,
@@ -42,24 +148,29 @@ export async function 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
}
try {
onStateChange?.(`Posting...`)
return await store.api.app.bsky.feed.post.create(
{did: store.me.did || ''},
{
text,
reply,
embed,
entities,
createdAt: new Date().toISOString(),
},
)
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)
if (isNetworkError(e)) {
throw new Error(
'Post failed to upload. Please check your Internet connection and try again.',
)
} else {
throw e
}
}
return await store.api.app.bsky.feed.post.create(
{did: store.me.did || ''},
{
text,
reply,
entities,
createdAt: new Date().toISOString(),
},
)
}
export async function repost(store: RootStoreModel, uri: string, cid: string) {
@@ -105,54 +216,6 @@ export async function unfollow(store: RootStoreModel, followUri: string) {
})
}
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>
+231 -193
View File
@@ -1,184 +1,170 @@
import {makeAutoObservable, runInAction} from 'mobx'
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 {
AppBskyFeedGetTimeline as GetTimeline,
AppBskyFeedFeedViewPost,
AppBskyFeedPost,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
} from '@atproto/api'
type FeedViewPost = AppBskyFeedFeedViewPost.Main
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
type PostView = AppBskyFeedPost.View
import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api'
import {cleanError} from '../../lib/strings'
import {isObj, hasProp} from '../lib/type-guards'
const PAGE_SIZE = 30
type FeedItem = GetTimeline.FeedItem | GetAuthorFeed.FeedItem
type FeedItemWithThreadMeta = FeedItem & {
let _idCounter = 0
type FeedViewPostWithThreadMeta = FeedViewPost & {
_isThreadParent?: boolean
_isThreadChildElided?: boolean
_isThreadChild?: boolean
}
export class FeedItemMyStateModel {
repost?: string
upvote?: string
downvote?: string
constructor() {
makeAutoObservable(this)
}
}
export class FeedItemModel implements GetTimeline.FeedItem {
export class FeedItemModel {
// ui state
_reactKey: string = ''
_isThreadParent: boolean = false
_isThreadChildElided: boolean = false
_isThreadChild: boolean = false
_hideParent: boolean = true // used to avoid dup post rendering while showing some parents
// data
uri: string = ''
cid: string = ''
author: ActorRef.WithInfo = {
did: '',
handle: '',
displayName: '',
declaration: {cid: '', actorType: ''},
avatar: undefined,
}
repostedBy?: ActorRef.WithInfo
trendedBy?: ActorRef.WithInfo
record: Record<string, unknown> = {}
replyCount: number = 0
repostCount: number = 0
upvoteCount: number = 0
downvoteCount: number = 0
indexedAt: string = ''
myState = new FeedItemMyStateModel()
// additional data
additionalParentPost?: PostThreadViewModel
post: PostView
postRecord?: AppBskyFeedPost.Record
reply?: FeedViewPost['reply']
replyParent?: FeedItemModel
reason?: FeedViewPost['reason']
constructor(
public rootStore: RootStoreModel,
reactKey: string,
v: FeedItemWithThreadMeta,
v: FeedViewPostWithThreadMeta,
) {
makeAutoObservable(this, {rootStore: false})
this._reactKey = reactKey
this.copy(v)
this.post = v.post
if (AppBskyFeedPost.isRecord(this.post.record)) {
const valid = AppBskyFeedPost.validateRecord(this.post.record)
if (valid.success) {
this.postRecord = this.post.record
} else {
rootStore.log.warn(
'Received an invalid app.bsky.feed.post record',
valid.error,
)
}
} else {
rootStore.log.warn(
'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
this.post.record,
)
}
this.reply = v.reply
if (v.reply?.parent) {
this.replyParent = new FeedItemModel(rootStore, '', {
post: v.reply.parent,
})
}
this.reason = v.reason
this._isThreadParent = v._isThreadParent || false
this._isThreadChild = v._isThreadChild || false
this._isThreadChildElided = v._isThreadChildElided || false
makeAutoObservable(this, {rootStore: false})
}
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.replyCount = v.replyCount
this.repostCount = v.repostCount
this.upvoteCount = v.upvoteCount
this.downvoteCount = v.downvoteCount
this.indexedAt = v.indexedAt
if (v.myState) {
this.myState.upvote = v.myState.upvote
this.myState.downvote = v.myState.downvote
this.myState.repost = v.myState.repost
copy(v: FeedViewPost) {
this.post = v.post
this.reply = v.reply
if (v.reply?.parent) {
this.replyParent = new FeedItemModel(this.rootStore, '', {
post: v.reply.parent,
})
} else {
this.replyParent = undefined
}
this.reason = v.reason
}
get reasonRepost(): ReasonRepost | undefined {
if (this.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost') {
return this.reason as ReasonRepost
}
}
async toggleUpvote() {
const wasUpvoted = !!this.myState.upvote
const wasDownvoted = !!this.myState.downvote
const wasUpvoted = !!this.post.viewer.upvote
const wasDownvoted = !!this.post.viewer.downvote
const res = await this.rootStore.api.app.bsky.feed.setVote({
subject: {
uri: this.uri,
cid: this.cid,
uri: this.post.uri,
cid: this.post.cid,
},
direction: wasUpvoted ? 'none' : 'up',
})
runInAction(() => {
if (wasDownvoted) {
this.downvoteCount--
this.post.downvoteCount--
}
if (wasUpvoted) {
this.upvoteCount--
this.post.upvoteCount--
} else {
this.upvoteCount++
this.post.upvoteCount++
}
this.myState.upvote = res.data.upvote
this.myState.downvote = res.data.downvote
this.post.viewer.upvote = res.data.upvote
this.post.viewer.downvote = res.data.downvote
})
}
async toggleDownvote() {
const wasUpvoted = !!this.myState.upvote
const wasDownvoted = !!this.myState.downvote
const wasUpvoted = !!this.post.viewer.upvote
const wasDownvoted = !!this.post.viewer.downvote
const res = await this.rootStore.api.app.bsky.feed.setVote({
subject: {
uri: this.uri,
cid: this.cid,
uri: this.post.uri,
cid: this.post.cid,
},
direction: wasDownvoted ? 'none' : 'down',
})
runInAction(() => {
if (wasUpvoted) {
this.upvoteCount--
this.post.upvoteCount--
}
if (wasDownvoted) {
this.downvoteCount--
this.post.downvoteCount--
} else {
this.downvoteCount++
this.post.downvoteCount++
}
this.myState.upvote = res.data.upvote
this.myState.downvote = res.data.downvote
this.post.viewer.upvote = res.data.upvote
this.post.viewer.downvote = res.data.downvote
})
}
async toggleRepost() {
if (this.myState.repost) {
await apilib.unrepost(this.rootStore, this.myState.repost)
if (this.post.viewer.repost) {
await apilib.unrepost(this.rootStore, this.post.viewer.repost)
runInAction(() => {
this.repostCount--
this.myState.repost = undefined
this.post.repostCount--
this.post.viewer.repost = undefined
})
} else {
const res = await apilib.repost(this.rootStore, this.uri, this.cid)
const res = await apilib.repost(
this.rootStore,
this.post.uri,
this.post.cid,
)
runInAction(() => {
this.repostCount++
this.myState.repost = res.uri
this.post.repostCount++
this.post.viewer.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)
did: this.post.author.did,
rkey: new AtUri(this.post.uri).rkey,
})
}
}
@@ -237,12 +223,11 @@ export class FeedModel {
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,
item =>
!item.reply || // not a reply
((item._isThreadParent || // but allow if it's a thread by the user
item._isThreadChild) &&
item.reply?.root.author.did === item.post.author.did),
)
}
@@ -274,7 +259,7 @@ export class FeedModel {
* Reset and load
*/
async refresh() {
return this.setup(true)
await this.setup(true)
}
/**
@@ -326,10 +311,15 @@ export class FeedModel {
}
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),
const currentLatestUri = this.pollCursor
const receivedLatestUri = res.data.feed[0]
? res.data.feed[0].post.uri
: undefined
const hasNewLatest = Boolean(
receivedLatestUri &&
(this.feed.length === 0 || receivedLatestUri !== currentLatestUri),
)
this.setHasNewLatest(hasNewLatest)
}
// state transitions
@@ -341,11 +331,14 @@ export class FeedModel {
this.error = ''
}
private _xIdle(err: string = '') {
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
this.error = err ? cleanError(err.toString()) : ''
if (err) {
this.rootStore.log.error('Posts feed request failed', err)
}
}
// loader functions
@@ -373,7 +366,7 @@ export class FeedModel {
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e.toString())
this._xIdle(e)
}
}
@@ -384,12 +377,12 @@ export class FeedModel {
await this._prependAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e.toString())
this._xIdle(e)
}
}
private async _loadMore() {
if (!this.hasMore) {
if (!this.hasMore || this.hasError) {
return
}
this._xLoading()
@@ -401,7 +394,7 @@ export class FeedModel {
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`)
this._xIdle(e)
}
}
@@ -423,19 +416,20 @@ export class FeedModel {
}
this._updateAll(res)
numToFetch -= res.data.feed.length
cursor = this.feed[res.data.feed.length - 1].indexedAt
console.log(numToFetch, cursor, res.data.feed.length)
cursor = this.feed[res.data.feed.length - 1]
? ts(this.feed[res.data.feed.length - 1])
: undefined
} while (numToFetch > 0)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to update feed: ${e.toString()}`)
this._xIdle(e)
}
}
private async _replaceAll(
res: GetTimeline.Response | GetAuthorFeed.Response,
) {
this.pollCursor = res.data.feed[0]?.uri
this.pollCursor = res.data.feed[0]?.post.uri
return this._appendAll(res, true)
}
@@ -445,73 +439,46 @@ export class FeedModel {
) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
let counter = this.feed.length
// 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 reorgedFeed = preprocessFeed(res.data.feed)
const promises = []
const toAppend: FeedItemModel[] = []
for (const item of reorgedFeed) {
const itemModel = new FeedItemModel(
this.rootStore,
`item-${counter++}`,
`item-${_idCounter++}`,
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)
}
dedupParents(this.feed)
})
}
private async _prependAll(
res: GetTimeline.Response | GetAuthorFeed.Response,
) {
this.pollCursor = res.data.feed[0]?.uri
let counter = this.feed.length
this.pollCursor = res.data.feed[0]?.post.uri
const promises = []
const toPrepend: FeedItemModel[] = []
for (const item of res.data.feed) {
if (this.feed.find(item2 => item2.uri === item.uri)) {
if (this.feed.find(item2 => item2.post.uri === item.post.uri)) {
break // stop here - we've hit a post we already have
}
const itemModel = new FeedItemModel(
this.rootStore,
`item-${counter++}`,
`item-${_idCounter++}`,
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)
})
@@ -520,10 +487,11 @@ export class FeedModel {
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
// 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,
// HACK: need to find the reposts' item, so we have to check for that -prf
item2 =>
item.post.uri === item2.post.uri &&
// @ts-ignore todo
item.reason?.by?.did === item2.reason?.by?.did,
)
if (existingItem) {
existingItem.copy(item)
@@ -547,23 +515,23 @@ export class FeedModel {
}
}
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
interface Slice {
index: number
length: number
}
function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
const reorg: FeedViewPostWithThreadMeta[] = []
if (dedup) {
if (reorg.find(item2 => item2.uri === item.uri)) {
continue
}
}
// phase one: identify threads and reorganize them into the feed so
// that they are in order and marked as part of a thread
for (let i = feed.length - 1; i >= 0; i--) {
const item = feed[i] as FeedViewPostWithThreadMeta
const selfReplyUri = getSelfReplyUri(item)
if (selfReplyUri) {
const parentIndex = reorg.findIndex(item2 => item2.uri === selfReplyUri)
const parentIndex = reorg.findIndex(
item2 => item2.post.uri === selfReplyUri,
)
if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) {
reorg[parentIndex]._isThreadParent = true
item._isThreadChild = true
@@ -573,23 +541,93 @@ function preprocessFeed(
}
reorg.unshift(item)
}
// phase two: identify the positions of the threads
let activeSlice = -1
let threadSlices: Slice[] = []
for (let i = 0; i < reorg.length; i++) {
const item = reorg[i] as FeedViewPostWithThreadMeta
if (activeSlice === -1) {
if (item._isThreadParent) {
activeSlice = i
}
} else {
if (!item._isThreadChild) {
threadSlices.push({index: activeSlice, length: i - activeSlice})
if (item._isThreadParent) {
activeSlice = i
} else {
activeSlice = -1
}
}
}
}
if (activeSlice !== -1) {
threadSlices.push({index: activeSlice, length: reorg.length - activeSlice})
}
// phase three: reorder the feed so that the timestamp of the
// last post in a thread establishes its ordering
for (const slice of threadSlices) {
const removed: FeedViewPostWithThreadMeta[] = reorg.splice(
slice.index,
slice.length,
)
const targetDate = new Date(ts(removed[removed.length - 1]))
let newIndex = reorg.findIndex(item => new Date(ts(item)) < targetDate)
if (newIndex === -1) {
newIndex = reorg.length
}
reorg.splice(newIndex, 0, ...removed)
slice.index = newIndex
}
// phase four: compress any threads that are longer than 3 posts
let removedCount = 0
for (const slice of threadSlices) {
if (slice.length > 3) {
reorg.splice(slice.index - removedCount + 1, slice.length - 3)
reorg[slice.index - removedCount]._isThreadChildElided = true
removedCount += slice.length - 3
}
}
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
function dedupParents(feed: FeedItemModel[]) {
// only show parents that aren't already in the feed
// NOTE if a parent post arrives in a followup loadmore, it'll show when we otherwise wish it wouldnt -prf
for (let i = 0; i < feed.length; i++) {
const item1 = feed[i]
if (!item1.replyParent || item1._isThreadChild) {
continue
}
let hideParent = false
for (let j = 0; j < feed.length; j++) {
const item2 = feed[j]
if (
item1.replyParent.post.uri === item2.post.uri || // the post itself is there
(j < i && item1.replyParent.post.uri === item2.replyParent?.post.uri) // another reply already showed it
) {
hideParent = true
break
}
}
item1._hideParent = hideParent
}
}
function getSelfReplyUri(item: FeedViewPost): string | undefined {
return item.reply?.parent.author.did === item.post.author.did
? item.reply?.parent.uri
: undefined
}
function ts(item: FeedViewPost | FeedItemModel): string {
if (item.reason?.indexedAt) {
// @ts-ignore need better type checks
return item.reason.indexedAt
}
return item.post.indexedAt
}
+8 -6
View File
@@ -1,9 +1,8 @@
import {makeAutoObservable} from 'mobx'
import * as GetAssertions from '../../third-party/api/src/client/types/app/bsky/graph/getAssertions'
import {AppBskyGraphGetAssertions as GetAssertions} from '@atproto/api'
import {RootStoreModel} from './root-store'
type ResponseAssertion = GetAssertions.OutputSchema['assertions'][number]
export type Assertion = ResponseAssertion & {
export type Assertion = GetAssertions.Assertion & {
_reactKey: string
}
@@ -81,11 +80,14 @@ export class GetAssertionsView {
this.error = ''
}
private _xIdle(err: string = '') {
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
this.error = err ? err.toString() : ''
if (err) {
this.rootStore.log.error('Failed to fetch assertions', err)
}
}
// loader functions
@@ -100,7 +102,7 @@ export class GetAssertionsView {
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e.toString())
this._xIdle(e)
}
}
+111
View File
@@ -0,0 +1,111 @@
import {makeAutoObservable} from 'mobx'
import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc'
import {isObj, hasProp} from '../lib/type-guards'
interface LogEntry {
id: string
type?: string
summary?: string
details?: string
ts?: number
}
let _lastTs: string
let _lastId: string
function genId(): string {
let candidate = String(Date.now())
if (_lastTs === candidate) {
const id = _lastId + 'x'
_lastId = id
return id
}
_lastTs = candidate
_lastId = candidate
return candidate
}
export class LogModel {
entries: LogEntry[] = []
constructor() {
makeAutoObservable(this, {serialize: false, hydrate: false})
}
serialize(): unknown {
return {
entries: this.entries.slice(-100),
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'entries') && Array.isArray(v.entries)) {
this.entries = v.entries.filter(
e => isObj(e) && hasProp(e, 'id') && typeof e.id === 'string',
)
}
}
}
private add(entry: LogEntry) {
this.entries.push(entry)
}
debug(summary: string, details?: any) {
details = detailsToStr(details)
console.debug(summary, details || '')
this.add({
id: genId(),
type: 'debug',
summary,
details,
ts: Date.now(),
})
}
warn(summary: string, details?: any) {
details = detailsToStr(details)
console.debug(summary, details || '')
this.add({
id: genId(),
type: 'warn',
summary,
details,
ts: Date.now(),
})
}
error(summary: string, details?: any) {
details = detailsToStr(details)
console.debug(summary, details || '')
this.add({
id: genId(),
type: 'error',
summary,
details,
ts: Date.now(),
})
}
}
function detailsToStr(details?: any) {
if (details && typeof details !== 'string') {
if (
details instanceof XRPCInvalidResponseError ||
details.constructor.name === 'XRPCInvalidResponseError'
) {
return `The server gave an ill-formatted response.\nMethod: ${
details.lexiconNsid
}.\nError: ${details.validationError.toString()}`
} else if (
details instanceof XRPCError ||
details.constructor.name === 'XRPCError'
) {
return `An XRPC error occurred.\nStatus: ${details.status}\nError: ${details.error}\nMessage: ${details.message}`
} else if (details instanceof Error) {
return details.toString()
}
return JSON.stringify(details, null, 2)
}
return details
}
+16 -15
View File
@@ -1,6 +1,6 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from './root-store'
import {MembershipsViewModel} from './memberships-view'
import {FeedModel} from './feed-view'
import {NotificationsViewModel} from './notifications-view'
import {isObj, hasProp} from '../lib/type-guards'
@@ -11,7 +11,7 @@ export class MeModel {
description: string = ''
avatar: string = ''
notificationCount: number = 0
memberships?: MembershipsViewModel
mainFeed: FeedModel
notifications: NotificationsViewModel
constructor(public rootStore: RootStoreModel) {
@@ -20,6 +20,9 @@ export class MeModel {
{rootStore: false, serialize: false, hydrate: false},
{autoBind: true},
)
this.mainFeed = new FeedModel(this.rootStore, 'home', {
algorithm: 'reverse-chronological',
})
this.notifications = new NotificationsViewModel(this.rootStore, {})
}
@@ -30,7 +33,6 @@ export class MeModel {
this.description = ''
this.avatar = ''
this.notificationCount = 0
this.memberships = undefined
}
serialize(): unknown {
@@ -90,15 +92,18 @@ export class MeModel {
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)
this.mainFeed = new FeedModel(this.rootStore, 'home', {
algorithm: 'reverse-chronological',
})
this.notifications = new NotificationsViewModel(this.rootStore, {})
await Promise.all([
this.mainFeed.setup().catch(e => {
this.rootStore.log.error('Failed to setup main feed model', e)
}),
this.notifications.setup().catch(e => {
this.rootStore.log.error('Failed to setup notifications model', e)
}),
])
} else {
this.clear()
}
@@ -119,8 +124,4 @@ export class MeModel {
}
})
}
async refreshMemberships() {
return this.memberships?.refresh()
}
}
-144
View File
@@ -1,144 +0,0 @@
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
@@ -1,122 +0,0 @@
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)
}
}
+73 -41
View File
@@ -1,21 +1,35 @@
import {makeAutoObservable, runInAction} from 'mobx'
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 {
AppBskyNotificationList as ListNotifications,
AppBskyActorRef as ActorRef,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedVote,
AppBskyGraphAssertion,
AppBskyGraphFollow,
} from '@atproto/api'
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'
const UNGROUPABLE_REASONS = ['trend', 'assertion']
const UNGROUPABLE_REASONS = ['assertion']
const PAGE_SIZE = 30
const MS_60MIN = 1e3 * 60 * 60
let _idCounter = 0
export interface GroupedNotification extends ListNotifications.Notification {
additional?: ListNotifications.Notification[]
}
export class NotificationsViewItemModel implements GroupedNotification {
type SupportedRecord =
| AppBskyFeedPost.Record
| AppBskyFeedRepost.Record
| AppBskyFeedVote.Record
| AppBskyGraphAssertion.Record
| AppBskyGraphFollow.Record
export class NotificationsViewItemModel {
// ui state
_reactKey: string = ''
@@ -25,11 +39,12 @@ export class NotificationsViewItemModel implements GroupedNotification {
author: ActorRef.WithInfo = {
did: '',
handle: '',
avatar: '',
declaration: {cid: '', actorType: ''},
}
reason: string = ''
reasonSubject?: string
record: any = {}
record?: SupportedRecord
isRead: boolean = false
indexedAt: string = ''
additional?: NotificationsViewItemModel[]
@@ -53,7 +68,7 @@ export class NotificationsViewItemModel implements GroupedNotification {
this.author = v.author
this.reason = v.reason
this.reasonSubject = v.reasonSubject
this.record = v.record
this.record = this.toSupportedRecord(v.record)
this.isRead = v.isRead
this.indexedAt = v.indexedAt
if (v.additional?.length) {
@@ -76,10 +91,6 @@ export class NotificationsViewItemModel implements GroupedNotification {
return this.reason === 'repost'
}
get isTrend() {
return this.reason === 'trend'
}
get isMention() {
return this.reason === 'mention'
}
@@ -97,37 +108,53 @@ export class NotificationsViewItemModel implements GroupedNotification {
}
get needsAdditionalData() {
if (
this.isUpvote ||
this.isRepost ||
this.isTrend ||
this.isReply ||
this.isMention
) {
if (this.isUpvote || this.isRepost || this.isReply || this.isMention) {
return !this.additionalPost
}
return false
}
get isInvite() {
return (
this.isAssertion && this.record.assertion === APP_BSKY_GRAPH.AssertMember
)
}
get subjectUri() {
get subjectUri(): string {
if (this.reasonSubject) {
return this.reasonSubject
}
const record = this.record
if (
hasProp(this.record, 'subject') &&
typeof this.record.subject === 'string'
AppBskyFeedRepost.isRecord(record) ||
AppBskyFeedVote.isRecord(record)
) {
return this.record.subject
return record.subject.uri
}
return ''
}
toSupportedRecord(v: unknown): SupportedRecord | undefined {
for (const ns of [
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedVote,
AppBskyGraphAssertion,
AppBskyGraphFollow,
]) {
if (ns.isRecord(v)) {
const valid = ns.validateRecord(v)
if (valid.success) {
return v
} else {
this.rootStore.log.warn('Received an invalid record', {
record: v,
error: valid.error,
})
return
}
}
}
this.rootStore.log.warn(
'app.bsky.notifications.list served an unsupported record type',
v,
)
}
async fetchAdditionalData() {
if (!this.needsAdditionalData) {
return
@@ -135,7 +162,7 @@ export class NotificationsViewItemModel implements GroupedNotification {
let postUri
if (this.isReply || this.isMention) {
postUri = this.uri
} else if (this.isUpvote || this.isRead || this.isTrend) {
} else if (this.isUpvote || this.isRepost) {
postUri = this.subjectUri
}
if (postUri) {
@@ -144,7 +171,10 @@ export class NotificationsViewItemModel implements GroupedNotification {
depth: 0,
})
await this.additionalPost.setup().catch(e => {
console.error('Failed to load post needed by notification', e)
this.rootStore.log.error(
'Failed to load post needed by notification',
e,
)
})
}
}
@@ -257,8 +287,8 @@ export class NotificationsViewModel {
seenAt: new Date().toISOString(),
})
this.rootStore.me.clearNotificationCount()
} catch (e) {
console.log('Failed to update notifications read state', e)
} catch (e: any) {
this.rootStore.log.warn('Failed to update notifications read state', e)
}
}
@@ -271,11 +301,15 @@ export class NotificationsViewModel {
this.error = ''
}
private _xIdle(err: string = '') {
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
this.error = err ? cleanError(err) : ''
if (err) {
this.rootStore.log.error('Failed to fetch notifications', err)
}
}
// loader functions
@@ -303,7 +337,7 @@ export class NotificationsViewModel {
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load notifications: ${e.toString()}`)
this._xIdle(e)
}
}
@@ -321,7 +355,7 @@ export class NotificationsViewModel {
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load notifications: ${e.toString()}`)
this._xIdle(e)
}
}
@@ -345,11 +379,10 @@ export class NotificationsViewModel {
this._updateAll(res)
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) {
this._xIdle(`Failed to update notifications: ${e.toString()}`)
this._xIdle(e)
}
}
@@ -360,13 +393,12 @@ export class NotificationsViewModel {
private async _appendAll(res: ListNotifications.Response, replace = false) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
let counter = this.notifications.length
const promises = []
const itemModels: NotificationsViewItemModel[] = []
for (const item of groupNotifications(res.data.notifications)) {
const itemModel = new NotificationsViewItemModel(
this.rootStore,
`item-${counter++}`,
`item-${_idCounter++}`,
item,
)
if (itemModel.needsAdditionalData) {
@@ -375,7 +407,7 @@ export class NotificationsViewModel {
itemModels.push(itemModel)
}
await Promise.all(promises).catch(e => {
console.error(
this.rootStore.log.error(
'Uncaught failure during notifications-view _appendAll()',
e,
)
+102 -133
View File
@@ -1,20 +1,12 @@
import {makeAutoObservable, runInAction} from 'mobx'
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 {
AppBskyFeedGetPostThread as GetPostThread,
AppBskyFeedPost as FeedPost,
} from '@atproto/api'
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) {
@@ -22,199 +14,164 @@ function* reactKeyGenerator(): Generator<string> {
}
}
interface ReplyingTo {
author: {
handle: string
displayName?: string
avatar?: string
}
text: string
}
interface OriginalRecord {
text: string
}
export class PostThreadViewPostMyStateModel {
repost?: string
upvote?: string
downvote?: string
constructor() {
makeAutoObservable(this)
}
}
export class PostThreadViewPostModel implements GetPostThread.Post {
export class PostThreadViewPostModel {
// ui state
_reactKey: string = ''
_depth = 0
_isHighlightedPost = false
_hasMore = false
// data
$type: string = ''
uri: string = ''
cid: string = ''
author: ActorRef.WithInfo = {
did: '',
handle: '',
declaration: {cid: '', actorType: ''},
}
record: Record<string, unknown> = {}
embed?: Embed.Main = undefined
parent?: PostThreadViewPostModel
replyCount: number = 0
replies?: PostThreadViewPostModel[]
repostCount: number = 0
upvoteCount: number = 0
downvoteCount: number = 0
indexedAt: string = ''
myState = new PostThreadViewPostMyStateModel()
// added data
replyingTo?: ReplyingTo
post: FeedPost.View
postRecord?: FeedPost.Record
parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost
replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[]
constructor(
public rootStore: RootStoreModel,
reactKey: string,
v?: GetPostThread.Post,
v: GetPostThread.ThreadViewPost,
) {
makeAutoObservable(this, {rootStore: false})
this._reactKey = reactKey
if (v) {
Object.assign(this, _omit(v, 'parent', 'replies', 'myState')) // replies and parent are handled via assignTreeModels
if (v.myState) {
Object.assign(this.myState, v.myState)
this.post = v.post
if (FeedPost.isRecord(this.post.record)) {
const valid = FeedPost.validateRecord(this.post.record)
if (valid.success) {
this.postRecord = this.post.record
} else {
rootStore.log.warn(
'Received an invalid app.bsky.feed.post record',
valid.error,
)
}
} else {
rootStore.log.warn(
'app.bsky.feed.getPostThread served an unexpected record type',
this.post.record,
)
}
// replies and parent are handled via assignTreeModels
makeAutoObservable(this, {rootStore: false})
}
assignTreeModels(
keyGen: Generator<string>,
v: GetPostThread.Post,
v: GetPostThread.ThreadViewPost,
includeParent = true,
includeChildren = true,
isFirstChild = true,
) {
// parents
if (includeParent && v.parent) {
// TODO: validate .record
const parentModel = new PostThreadViewPostModel(
this.rootStore,
keyGen.next().value,
v.parent,
)
parentModel._depth = this._depth - 1
if (v.parent.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,
if (GetPostThread.isThreadViewPost(v.parent)) {
const parentModel = new PostThreadViewPostModel(
this.rootStore,
keyGen.next().value,
v.parent,
)
parentModel._depth = this._depth - 1
if (v.parent.parent) {
parentModel.assignTreeModels(keyGen, v.parent, true, false)
}
this.parent = parentModel
} else if (GetPostThread.isNotFoundPost(v.parent)) {
this.parent = v.parent
}
}
// replies
if (includeChildren && v.replies) {
const replies = []
let isChildFirstChild = true
for (const item of v.replies) {
// TODO: validate .record
const itemModel = new PostThreadViewPostModel(
this.rootStore,
keyGen.next().value,
item,
)
itemModel._depth = this._depth + 1
if (item.replies) {
itemModel.assignTreeModels(
keyGen,
if (GetPostThread.isThreadViewPost(item)) {
const itemModel = new PostThreadViewPostModel(
this.rootStore,
keyGen.next().value,
item,
false,
true,
isChildFirstChild,
)
itemModel._depth = this._depth + 1
if (item.replies) {
itemModel.assignTreeModels(keyGen, item, false, true)
}
replies.push(itemModel)
} else if (GetPostThread.isNotFoundPost(item)) {
replies.push(item)
}
isChildFirstChild = false
replies.push(itemModel)
}
this.replies = replies
}
}
async toggleUpvote() {
const wasUpvoted = !!this.myState.upvote
const wasDownvoted = !!this.myState.downvote
const wasUpvoted = !!this.post.viewer.upvote
const wasDownvoted = !!this.post.viewer.downvote
const res = await this.rootStore.api.app.bsky.feed.setVote({
subject: {
uri: this.uri,
cid: this.cid,
uri: this.post.uri,
cid: this.post.cid,
},
direction: wasUpvoted ? 'none' : 'up',
})
runInAction(() => {
if (wasDownvoted) {
this.downvoteCount--
this.post.downvoteCount--
}
if (wasUpvoted) {
this.upvoteCount--
this.post.upvoteCount--
} else {
this.upvoteCount++
this.post.upvoteCount++
}
this.myState.upvote = res.data.upvote
this.myState.downvote = res.data.downvote
this.post.viewer.upvote = res.data.upvote
this.post.viewer.downvote = res.data.downvote
})
}
async toggleDownvote() {
const wasUpvoted = !!this.myState.upvote
const wasDownvoted = !!this.myState.downvote
const wasUpvoted = !!this.post.viewer.upvote
const wasDownvoted = !!this.post.viewer.downvote
const res = await this.rootStore.api.app.bsky.feed.setVote({
subject: {
uri: this.uri,
cid: this.cid,
uri: this.post.uri,
cid: this.post.cid,
},
direction: wasDownvoted ? 'none' : 'down',
})
runInAction(() => {
if (wasUpvoted) {
this.upvoteCount--
this.post.upvoteCount--
}
if (wasDownvoted) {
this.downvoteCount--
this.post.downvoteCount--
} else {
this.downvoteCount++
this.post.downvoteCount++
}
this.myState.upvote = res.data.upvote
this.myState.downvote = res.data.downvote
this.post.viewer.upvote = res.data.upvote
this.post.viewer.downvote = res.data.downvote
})
}
async toggleRepost() {
if (this.myState.repost) {
await apilib.unrepost(this.rootStore, this.myState.repost)
if (this.post.viewer.repost) {
await apilib.unrepost(this.rootStore, this.post.viewer.repost)
runInAction(() => {
this.repostCount--
this.myState.repost = undefined
this.post.repostCount--
this.post.viewer.repost = undefined
})
} else {
const res = await apilib.repost(this.rootStore, this.uri, this.cid)
const res = await apilib.repost(
this.rootStore,
this.post.uri,
this.post.cid,
)
runInAction(() => {
this.repostCount++
this.myState.repost = res.uri
this.post.repostCount++
this.post.viewer.repost = res.uri
})
}
}
async delete() {
await this.rootStore.api.app.bsky.feed.post.delete({
did: this.author.did,
rkey: new AtUri(this.uri).rkey,
did: this.post.author.did,
rkey: new AtUri(this.post.uri).rkey,
})
}
}
@@ -299,11 +256,14 @@ export class PostThreadViewModel {
this.notFound = false
}
private _xIdle(err: any = undefined) {
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err ? err.toString() : ''
if (err) {
this.rootStore.log.error('Failed to fetch post thread', err)
}
this.notFound = err instanceof GetPostThread.NotFoundError
}
@@ -313,7 +273,11 @@ export class PostThreadViewModel {
private async _resolveUri() {
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
urip.host = await this.rootStore.resolveName(urip.host)
try {
urip.host = await this.rootStore.resolveName(urip.host)
} catch (e: any) {
this.error = e.toString()
}
}
runInAction(() => {
this.resolvedUri = urip.toString()
@@ -334,20 +298,24 @@ export class PostThreadViewModel {
}
private _replaceAll(res: GetPostThread.Response) {
// TODO: validate .record
sortThread(res.data.thread)
// sortThread(res.data.thread) TODO needed?
const keyGen = reactKeyGenerator()
const thread = new PostThreadViewPostModel(
this.rootStore,
keyGen.next().value,
res.data.thread as GetPostThread.Post,
res.data.thread as GetPostThread.ThreadViewPost,
)
thread._isHighlightedPost = true
thread.assignTreeModels(keyGen, res.data.thread as GetPostThread.Post)
thread.assignTreeModels(
keyGen,
res.data.thread as GetPostThread.ThreadViewPost,
)
this.thread = thread
}
}
/*
TODO needed?
function sortThread(post: MaybePost) {
if (post.notFound) {
return
@@ -378,3 +346,4 @@ function sortThread(post: MaybePost) {
post.replies.forEach(reply => sortThread(reply))
}
}
*/
+7 -3
View File
@@ -1,5 +1,5 @@
import {makeAutoObservable} from 'mobx'
import * as Post from '../../third-party/api/src/client/types/app/bsky/feed/post'
import {AppBskyFeedPost as Post} from '@atproto/api'
import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from './root-store'
import {cleanError} from '../../lib/strings'
@@ -63,10 +63,14 @@ export class PostModel implements RemoveIndex<Post.Record> {
this.error = ''
}
private _xIdle(err: string = '') {
private _xIdle(err?: any) {
this.isLoading = false
this.hasLoaded = true
this.error = cleanError(err)
this.error = err ? cleanError(err) : ''
if (err) {
this.rootStore.log.error('Failed to fetch post', err)
}
}
// loader functions
@@ -87,7 +91,7 @@ export class PostModel implements RemoveIndex<Post.Record> {
this._replaceAll(res.value)
this._xIdle()
} catch (e: any) {
this._xIdle(e.toString())
this._xIdle(e)
}
}
+5 -42
View File
@@ -1,24 +1,14 @@
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]
const USER_SELECTOR_ITEMS = [Sections.Posts, Sections.PostsWithReplies]
export interface ProfileUiParams {
user: string
@@ -28,8 +18,6 @@ export class ProfileUiModel {
// data
profile: ProfileViewModel
feed: FeedModel
memberships: MembershipsViewModel
members: MembersViewModel
// ui state
selectedViewIndex = 0
@@ -51,24 +39,15 @@ export class ProfileUiModel {
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 {
get currentView(): FeedModel {
if (
this.selectedView === Sections.Posts ||
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.Trending
this.selectedView === Sections.PostsWithReplies
) {
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}`)
}
@@ -85,15 +64,9 @@ export class ProfileUiModel {
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
}
@@ -114,21 +87,11 @@ export class ProfileUiModel {
await Promise.all([
this.profile
.setup()
.catch(err => console.error('Failed to fetch profile', err)),
.catch(err => this.rootStore.log.error('Failed to fetch profile', err)),
this.feed
.setup()
.catch(err => console.error('Failed to fetch feed', err)),
.catch(err => this.rootStore.log.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() {
+42 -23
View File
@@ -1,19 +1,22 @@
import {makeAutoObservable, runInAction} from 'mobx'
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 {
AppBskyActorGetProfile as GetProfile,
AppBskyActorProfile as Profile,
AppBskySystemDeclRef,
AppBskyFeedPost,
} from '@atproto/api'
type DeclRef = AppBskySystemDeclRef.Main
type Entity = AppBskyFeedPost.Entity
import {extractEntities} from '../../lib/strings'
import {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 {
follow?: string
member?: string
muted?: boolean
constructor() {
makeAutoObservable(this)
@@ -39,15 +42,12 @@ export class ProfileViewModel {
displayName?: string
description?: string
avatar?: string
banner?: string
followersCount: number = 0
followsCount: number = 0
membersCount: number = 0
postsCount: number = 0
myState = new ProfileViewMyStateModel()
// TODO TEMP data to be implemented in the protocol
userBanner: string | null = null
// added data
descriptionEntities?: Entity[]
@@ -82,10 +82,6 @@ export class ProfileViewModel {
return this.declaration.actorType === ACTOR_TYPE_USER
}
get isScene() {
return this.declaration.actorType === ACTOR_TYPE_SCENE
}
// public api
// =
@@ -123,11 +119,8 @@ export class ProfileViewModel {
async updateProfile(
updates: Profile.Record,
newUserAvatar: PickedImage | undefined,
userBanner: string | null, // TODO TEMP
newUserBanner: PickedImage | undefined,
) {
// 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
@@ -140,11 +133,35 @@ export class ProfileViewModel {
mimeType: newUserAvatar.mime,
}
}
if (newUserBanner) {
const res = await this.rootStore.api.com.atproto.blob.upload(
newUserBanner.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{
encoding: newUserBanner.mime,
},
)
updates.banner = {
cid: res.data.cid,
mimeType: newUserBanner.mime,
}
}
await this.rootStore.api.app.bsky.actor.updateProfile(updates)
await this.rootStore.me.load()
await this.refresh()
}
async muteAccount() {
await this.rootStore.api.app.bsky.graph.mute({user: this.did})
this.myState.muted = true
await this.refresh()
}
async unmuteAccount() {
await this.rootStore.api.app.bsky.graph.unmute({user: this.did})
this.myState.muted = false
await this.refresh()
}
// state transitions
// =
@@ -154,11 +171,14 @@ export class ProfileViewModel {
this.error = ''
}
private _xIdle(err: string = '') {
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
this.error = err ? err.toString() : ''
if (err) {
this.rootStore.log.error('Failed to fetch profile', err)
}
}
// loader functions
@@ -174,12 +194,11 @@ export class ProfileViewModel {
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e.toString())
this._xIdle(e)
}
}
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)
@@ -187,9 +206,9 @@ export class ProfileViewModel {
this.displayName = res.data.displayName
this.description = res.data.description
this.avatar = res.data.avatar
this.banner = res.data.banner
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)
+1 -1
View File
@@ -1,7 +1,7 @@
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'
import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
type CacheValue = Promise<GetProfile.Response> | GetProfile.Response
export class ProfilesViewModel {
+16 -6
View File
@@ -1,7 +1,10 @@
import {makeAutoObservable, runInAction} from 'mobx'
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 {
AppBskyFeedGetRepostedBy as GetRepostedBy,
AppBskySystemDeclRef,
} from '@atproto/api'
type DeclRef = AppBskySystemDeclRef.Main
import {RootStoreModel} from './root-store'
export class RepostedByViewItemModel implements GetRepostedBy.RepostedBy {
@@ -91,11 +94,14 @@ export class RepostedByViewModel {
this.error = ''
}
private _xIdle(err: string = '') {
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
this.error = err ? err.toString() : ''
if (err) {
this.rootStore.log.error('Failed to fetch reposted by view', err)
}
}
// loader functions
@@ -104,7 +110,11 @@ export class RepostedByViewModel {
private async _resolveUri() {
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
urip.host = await this.rootStore.resolveName(urip.host)
try {
urip.host = await this.rootStore.resolveName(urip.host)
} catch (e: any) {
this.error = e.toString()
}
}
runInAction(() => {
this.resolvedUri = urip.toString()
@@ -120,7 +130,7 @@ export class RepostedByViewModel {
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`)
this._xIdle(e)
}
}
+15 -6
View File
@@ -3,10 +3,10 @@
*/
import {makeAutoObservable} from 'mobx'
import {sessionClient as AtpApi} from '../../third-party/api'
import type {SessionServiceClient} from '../../third-party/api/src/index'
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
import {createContext, useContext} from 'react'
import {isObj, hasProp} from '../lib/type-guards'
import {LogModel} from './log'
import {SessionModel} from './session'
import {NavigationModel} from './navigation'
import {ShellUiModel} from './shell-ui'
@@ -17,6 +17,7 @@ import {OnboardModel} from './onboard'
import {isNetworkError} from '../../lib/errors'
export class RootStoreModel {
log = new LogModel()
session = new SessionModel(this)
nav = new NavigationModel()
shell = new ShellUiModel()
@@ -54,27 +55,29 @@ export class RootStoreModel {
await this.session.connect()
}
await this.me.fetchStateUpdate()
} catch (e: unknown) {
} catch (e: any) {
if (isNetworkError(e)) {
this.session.setOnline(false) // connection lost
}
console.error('Failed to fetch latest state', e)
this.log.error('Failed to fetch latest state', e)
}
}
serialize(): unknown {
return {
log: this.log.serialize(),
session: this.session.serialize(),
me: this.me.serialize(),
nav: this.nav.serialize(),
onboard: this.onboard.serialize(),
shell: this.shell.serialize(),
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
if (hasProp(v, 'log')) {
this.log.hydrate(v.log)
}
if (hasProp(v, 'me')) {
this.me.hydrate(v.me)
@@ -85,6 +88,12 @@ export class RootStoreModel {
if (hasProp(v, 'onboard')) {
this.onboard.hydrate(v.onboard)
}
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}
if (hasProp(v, 'shell')) {
this.shell.hydrate(v.shell)
}
}
}
+17 -12
View File
@@ -1,10 +1,10 @@
import {makeAutoObservable} from 'mobx'
import {sessionClient as AtpApi} from '../../third-party/api/index'
import type {
SessionServiceClient,
import {
sessionClient as AtpApi,
Session,
} from '../../third-party/api/src/index'
import type * as GetAccountsConfig from '../../third-party/api/src/client/types/com/atproto/server/getAccountsConfig'
SessionServiceClient,
ComAtprotoServerGetAccountsConfig as GetAccountsConfig,
} from '@atproto/api'
import {isObj, hasProp} from '../lib/type-guards'
import {RootStoreModel} from './root-store'
import {isNetworkError} from '../../lib/errors'
@@ -89,6 +89,7 @@ export class SessionModel {
clear() {
this.data = null
this.setOnline(false)
}
setState(data: SessionData) {
@@ -120,11 +121,11 @@ export class SessionModel {
try {
const serviceUri = new URL(this.data.service)
this.rootStore.api.xrpc.uri = serviceUri
} catch (e) {
console.error(
} catch (e: any) {
this.rootStore.log.error(
`Invalid service URL: ${this.data.service}. Resetting session.`,
e,
)
console.error(e)
this.clear()
return false
}
@@ -159,7 +160,7 @@ export class SessionModel {
this.rootStore.me.clear()
}
this.rootStore.me.load().catch(e => {
console.error('Failed to fetch local user information', e)
this.rootStore.log.error('Failed to fetch local user information', e)
})
return // success
}
@@ -201,8 +202,9 @@ export class SessionModel {
did: res.data.did,
})
this.configureApi()
this.setOnline(true, false)
this.rootStore.me.load().catch(e => {
console.error('Failed to fetch local user information', e)
this.rootStore.log.error('Failed to fetch local user information', e)
})
}
}
@@ -238,7 +240,7 @@ export class SessionModel {
this.rootStore.onboard.start()
this.configureApi()
this.rootStore.me.load().catch(e => {
console.error('Failed to fetch local user information', e)
this.rootStore.log.error('Failed to fetch local user information', e)
})
}
}
@@ -246,7 +248,10 @@ export class SessionModel {
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.log.warn(
'(Minor issue) Failed to delete session on the server',
e,
)
})
}
this.rootStore.clearAll()
+125 -28
View File
@@ -1,7 +1,8 @@
import {makeAutoObservable} from 'mobx'
import {ProfileViewModel} from './profile-view'
import {isObj, hasProp} from '../lib/type-guards'
export class ConfirmModel {
export class ConfirmModal {
name = 'confirm'
constructor(
@@ -13,7 +14,7 @@ export class ConfirmModel {
}
}
export class EditProfileModel {
export class EditProfileModal {
name = 'edit-profile'
constructor(
@@ -24,23 +25,7 @@ export class EditProfileModel {
}
}
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 {
export class ServerInputModal {
name = 'server-input'
constructor(
@@ -51,6 +36,74 @@ export class ServerInputModel {
}
}
export class ReportPostModal {
name = 'report-post'
constructor(public postUrl: string) {
makeAutoObservable(this)
}
}
export class ReportAccountModal {
name = 'report-account'
constructor(public did: string) {
makeAutoObservable(this)
}
}
interface LightboxModel {
canSwipeLeft: boolean
canSwipeRight: boolean
onSwipeLeft: () => void
onSwipeRight: () => void
}
export class ProfileImageLightbox implements LightboxModel {
name = 'profile-image'
canSwipeLeft = false
canSwipeRight = false
constructor(public profileView: ProfileViewModel) {
makeAutoObservable(this)
}
onSwipeLeft() {}
onSwipeRight() {}
}
export class ImageLightbox implements LightboxModel {
name = 'image'
canSwipeLeft = true
canSwipeRight = true
constructor(public uri: string) {
makeAutoObservable(this)
}
onSwipeLeft() {}
onSwipeRight() {}
}
export class ImagesLightbox implements LightboxModel {
name = 'images'
get canSwipeLeft() {
return this.index > 0
}
get canSwipeRight() {
return this.index < this.uris.length - 1
}
constructor(public uris: string[], public index: number) {
makeAutoObservable(this)
}
onSwipeLeft() {
if (this.canSwipeLeft) {
this.index = this.index - 1
}
}
onSwipeRight() {
if (this.canSwipeRight) {
this.index = this.index + 1
}
}
}
export interface ComposerOptsPostRef {
uri: string
cid: string
@@ -67,19 +120,50 @@ export interface ComposerOpts {
}
export class ShellUiModel {
darkMode = false
minimalShellMode = false
isMainMenuOpen = false
isModalActive = false
activeModal:
| ConfirmModel
| EditProfileModel
| CreateSceneModel
| ServerInputModel
| ConfirmModal
| EditProfileModal
| ServerInputModal
| ReportPostModal
| ReportAccountModal
| undefined
isLightboxActive = false
activeLightbox:
| ProfileImageLightbox
| ImageLightbox
| ImagesLightbox
| undefined
isComposerActive = false
composerOpts: ComposerOpts | undefined
constructor() {
makeAutoObservable(this)
makeAutoObservable(this, {serialize: false, hydrate: false})
}
serialize(): unknown {
return {
darkMode: this.darkMode,
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'darkMode') && typeof v.darkMode === 'boolean') {
this.darkMode = v.darkMode
}
}
}
setDarkMode(v: boolean) {
this.darkMode = v
}
setMinimalShellMode(v: boolean) {
this.minimalShellMode = v
}
setMainMenuOpen(v: boolean) {
@@ -88,10 +172,11 @@ export class ShellUiModel {
openModal(
modal:
| ConfirmModel
| EditProfileModel
| CreateSceneModel
| ServerInputModel,
| ConfirmModal
| EditProfileModal
| ServerInputModal
| ReportPostModal
| ReportAccountModal,
) {
this.isModalActive = true
this.activeModal = modal
@@ -102,6 +187,18 @@ export class ShellUiModel {
this.activeModal = undefined
}
openLightbox(
lightbox: ProfileImageLightbox | ImageLightbox | ImagesLightbox,
) {
this.isLightboxActive = true
this.activeLightbox = lightbox
}
closeLightbox() {
this.isLightboxActive = false
this.activeLightbox = undefined
}
openComposer(opts: ComposerOpts) {
this.isComposerActive = true
this.composerOpts = opts
+7 -4
View File
@@ -1,5 +1,5 @@
import {makeAutoObservable} from 'mobx'
import * as GetSuggestions from '../../third-party/api/src/client/types/app/bsky/actor/getSuggestions'
import {AppBskyActorGetSuggestions as GetSuggestions} from '@atproto/api'
import {RootStoreModel} from './root-store'
export type SuggestedActor = GetSuggestions.Actor & {
@@ -58,11 +58,14 @@ export class SuggestedActorsViewModel {
this.error = ''
}
private _xIdle(err: string = '') {
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
this.error = err ? err.toString() : ''
if (err) {
this.rootStore.log.error('Failed to fetch suggested actors', err)
}
}
// loader functions
@@ -88,7 +91,7 @@ export class SuggestedActorsViewModel {
)
this._xIdle()
} catch (e: any) {
this._xIdle(e.toString())
this._xIdle(e)
}
}
-133
View File
@@ -1,133 +0,0 @@
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()
}
}
+4 -2
View File
@@ -1,6 +1,8 @@
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 {
AppBskyGraphGetFollows as GetFollows,
AppBskyActorSearchTypeahead as SearchTypeahead,
} from '@atproto/api'
import {RootStoreModel} from './root-store'
export class UserAutocompleteViewModel {
+9 -4
View File
@@ -1,6 +1,8 @@
import {makeAutoObservable} from 'mobx'
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 {
AppBskyGraphGetFollowers as GetFollowers,
AppBskyActorRef as ActorRef,
} from '@atproto/api'
import {RootStoreModel} from './root-store'
export type FollowerItem = GetFollowers.Follower & {
@@ -74,11 +76,14 @@ export class UserFollowersViewModel {
this.error = ''
}
private _xIdle(err: string = '') {
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
this.error = err ? err.toString() : ''
if (err) {
this.rootStore.log.error('Failed to fetch user followers', err)
}
}
// loader functions
+9 -4
View File
@@ -1,6 +1,8 @@
import {makeAutoObservable} from 'mobx'
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 {
AppBskyGraphGetFollows as GetFollows,
AppBskyActorRef as ActorRef,
} from '@atproto/api'
import {RootStoreModel} from './root-store'
export type FollowItem = GetFollows.Follow & {
@@ -74,11 +76,14 @@ export class UserFollowsViewModel {
this.error = ''
}
private _xIdle(err: string = '') {
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
this.error = err ? err.toString() : ''
if (err) {
this.rootStore.log.error('Failed to fetch user follows', err)
}
}
// loader functions
+15 -6
View File
@@ -1,7 +1,9 @@
import {makeAutoObservable, runInAction} from 'mobx'
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 {
AppBskyFeedGetVotes as GetVotes,
AppBskyActorRef as ActorRef,
} from '@atproto/api'
import {RootStoreModel} from './root-store'
export class VotesViewItemModel implements GetVotes.Vote {
@@ -89,11 +91,14 @@ export class VotesViewModel {
this.error = ''
}
private _xIdle(err: string = '') {
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
this.error = err ? err.toString() : ''
if (err) {
this.rootStore.log.error('Failed to fetch votes', err)
}
}
// loader functions
@@ -102,7 +107,11 @@ export class VotesViewModel {
private async _resolveUri() {
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
urip.host = await this.rootStore.resolveName(urip.host)
try {
urip.host = await this.rootStore.resolveName(urip.host)
} catch (e: any) {
this.error = e.toString()
}
}
runInAction(() => {
this.resolvedUri = urip.toString()
@@ -118,7 +127,7 @@ export class VotesViewModel {
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`)
this._xIdle(e)
}
}
-8660
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
-455
View File
@@ -1,455 +0,0 @@
import { Client as XrpcClient, ServiceClient as XrpcServiceClient } from '@atproto/xrpc';
import * as ComAtprotoAccountCreate from './types/com/atproto/account/create';
import * as ComAtprotoAccountCreateInviteCode from './types/com/atproto/account/createInviteCode';
import * as ComAtprotoAccountDelete from './types/com/atproto/account/delete';
import * as ComAtprotoAccountGet from './types/com/atproto/account/get';
import * as ComAtprotoAccountRequestPasswordReset from './types/com/atproto/account/requestPasswordReset';
import * as ComAtprotoAccountResetPassword from './types/com/atproto/account/resetPassword';
import * as ComAtprotoBlobUpload from './types/com/atproto/blob/upload';
import * as ComAtprotoHandleResolve from './types/com/atproto/handle/resolve';
import * as ComAtprotoRepoBatchWrite from './types/com/atproto/repo/batchWrite';
import * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createRecord';
import * as ComAtprotoRepoDeleteRecord from './types/com/atproto/repo/deleteRecord';
import * as ComAtprotoRepoDescribe from './types/com/atproto/repo/describe';
import * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord';
import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords';
import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord';
import * as ComAtprotoServerGetAccountsConfig from './types/com/atproto/server/getAccountsConfig';
import * as ComAtprotoSessionCreate from './types/com/atproto/session/create';
import * as ComAtprotoSessionDelete from './types/com/atproto/session/delete';
import * as ComAtprotoSessionGet from './types/com/atproto/session/get';
import * as ComAtprotoSessionRefresh from './types/com/atproto/session/refresh';
import * as ComAtprotoSyncGetRepo from './types/com/atproto/sync/getRepo';
import * as ComAtprotoSyncGetRoot from './types/com/atproto/sync/getRoot';
import * as ComAtprotoSyncUpdateRepo from './types/com/atproto/sync/updateRepo';
import * as AppBskyActorCreateScene from './types/app/bsky/actor/createScene';
import * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile';
import * as AppBskyActorGetSuggestions from './types/app/bsky/actor/getSuggestions';
import * as AppBskyActorProfile from './types/app/bsky/actor/profile';
import * as AppBskyActorSearch from './types/app/bsky/actor/search';
import * as AppBskyActorSearchTypeahead from './types/app/bsky/actor/searchTypeahead';
import * as AppBskyActorUpdateProfile from './types/app/bsky/actor/updateProfile';
import * as AppBskyFeedGetAuthorFeed from './types/app/bsky/feed/getAuthorFeed';
import * as AppBskyFeedGetPostThread from './types/app/bsky/feed/getPostThread';
import * as AppBskyFeedGetRepostedBy from './types/app/bsky/feed/getRepostedBy';
import * as AppBskyFeedGetTimeline from './types/app/bsky/feed/getTimeline';
import * as AppBskyFeedGetVotes from './types/app/bsky/feed/getVotes';
import * as AppBskyFeedPost from './types/app/bsky/feed/post';
import * as AppBskyFeedRepost from './types/app/bsky/feed/repost';
import * as AppBskyFeedSetVote from './types/app/bsky/feed/setVote';
import * as AppBskyFeedTrend from './types/app/bsky/feed/trend';
import * as AppBskyFeedVote from './types/app/bsky/feed/vote';
import * as AppBskyGraphAssertion from './types/app/bsky/graph/assertion';
import * as AppBskyGraphConfirmation from './types/app/bsky/graph/confirmation';
import * as AppBskyGraphFollow from './types/app/bsky/graph/follow';
import * as AppBskyGraphGetAssertions from './types/app/bsky/graph/getAssertions';
import * as AppBskyGraphGetFollowers from './types/app/bsky/graph/getFollowers';
import * as AppBskyGraphGetFollows from './types/app/bsky/graph/getFollows';
import * as AppBskyGraphGetMembers from './types/app/bsky/graph/getMembers';
import * as AppBskyGraphGetMemberships from './types/app/bsky/graph/getMemberships';
import * as AppBskyNotificationGetCount from './types/app/bsky/notification/getCount';
import * as AppBskyNotificationList from './types/app/bsky/notification/list';
import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen';
import * as AppBskySystemDeclaration from './types/app/bsky/system/declaration';
export * as ComAtprotoAccountCreate from './types/com/atproto/account/create';
export * as ComAtprotoAccountCreateInviteCode from './types/com/atproto/account/createInviteCode';
export * as ComAtprotoAccountDelete from './types/com/atproto/account/delete';
export * as ComAtprotoAccountGet from './types/com/atproto/account/get';
export * as ComAtprotoAccountRequestPasswordReset from './types/com/atproto/account/requestPasswordReset';
export * as ComAtprotoAccountResetPassword from './types/com/atproto/account/resetPassword';
export * as ComAtprotoBlobUpload from './types/com/atproto/blob/upload';
export * as ComAtprotoHandleResolve from './types/com/atproto/handle/resolve';
export * as ComAtprotoRepoBatchWrite from './types/com/atproto/repo/batchWrite';
export * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createRecord';
export * as ComAtprotoRepoDeleteRecord from './types/com/atproto/repo/deleteRecord';
export * as ComAtprotoRepoDescribe from './types/com/atproto/repo/describe';
export * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord';
export * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords';
export * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord';
export * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef';
export * as ComAtprotoServerGetAccountsConfig from './types/com/atproto/server/getAccountsConfig';
export * as ComAtprotoSessionCreate from './types/com/atproto/session/create';
export * as ComAtprotoSessionDelete from './types/com/atproto/session/delete';
export * as ComAtprotoSessionGet from './types/com/atproto/session/get';
export * as ComAtprotoSessionRefresh from './types/com/atproto/session/refresh';
export * as ComAtprotoSyncGetRepo from './types/com/atproto/sync/getRepo';
export * as ComAtprotoSyncGetRoot from './types/com/atproto/sync/getRoot';
export * as ComAtprotoSyncUpdateRepo from './types/com/atproto/sync/updateRepo';
export * as AppBskyActorCreateScene from './types/app/bsky/actor/createScene';
export * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile';
export * as AppBskyActorGetSuggestions from './types/app/bsky/actor/getSuggestions';
export * as AppBskyActorProfile from './types/app/bsky/actor/profile';
export * as AppBskyActorRef from './types/app/bsky/actor/ref';
export * as AppBskyActorSearch from './types/app/bsky/actor/search';
export * as AppBskyActorSearchTypeahead from './types/app/bsky/actor/searchTypeahead';
export * as AppBskyActorUpdateProfile from './types/app/bsky/actor/updateProfile';
export * as AppBskyFeedEmbed from './types/app/bsky/feed/embed';
export * as AppBskyFeedGetAuthorFeed from './types/app/bsky/feed/getAuthorFeed';
export * as AppBskyFeedGetPostThread from './types/app/bsky/feed/getPostThread';
export * as AppBskyFeedGetRepostedBy from './types/app/bsky/feed/getRepostedBy';
export * as AppBskyFeedGetTimeline from './types/app/bsky/feed/getTimeline';
export * as AppBskyFeedGetVotes from './types/app/bsky/feed/getVotes';
export * as AppBskyFeedPost from './types/app/bsky/feed/post';
export * as AppBskyFeedRepost from './types/app/bsky/feed/repost';
export * as AppBskyFeedSetVote from './types/app/bsky/feed/setVote';
export * as AppBskyFeedTrend from './types/app/bsky/feed/trend';
export * as AppBskyFeedVote from './types/app/bsky/feed/vote';
export * as AppBskyGraphAssertCreator from './types/app/bsky/graph/assertCreator';
export * as AppBskyGraphAssertMember from './types/app/bsky/graph/assertMember';
export * as AppBskyGraphAssertion from './types/app/bsky/graph/assertion';
export * as AppBskyGraphConfirmation from './types/app/bsky/graph/confirmation';
export * as AppBskyGraphFollow from './types/app/bsky/graph/follow';
export * as AppBskyGraphGetAssertions from './types/app/bsky/graph/getAssertions';
export * as AppBskyGraphGetFollowers from './types/app/bsky/graph/getFollowers';
export * as AppBskyGraphGetFollows from './types/app/bsky/graph/getFollows';
export * as AppBskyGraphGetMembers from './types/app/bsky/graph/getMembers';
export * as AppBskyGraphGetMemberships from './types/app/bsky/graph/getMemberships';
export * as AppBskyNotificationGetCount from './types/app/bsky/notification/getCount';
export * as AppBskyNotificationList from './types/app/bsky/notification/list';
export * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen';
export * as AppBskySystemActorScene from './types/app/bsky/system/actorScene';
export * as AppBskySystemActorUser from './types/app/bsky/system/actorUser';
export * as AppBskySystemDeclRef from './types/app/bsky/system/declRef';
export * as AppBskySystemDeclaration from './types/app/bsky/system/declaration';
export declare const APP_BSKY_GRAPH: {
AssertCreator: string;
AssertMember: string;
};
export declare const APP_BSKY_SYSTEM: {
ActorScene: string;
ActorUser: string;
};
export declare class Client {
xrpc: XrpcClient;
constructor();
service(serviceUri: string | URL): ServiceClient;
}
declare const defaultInst: Client;
export default defaultInst;
export declare class ServiceClient {
_baseClient: Client;
xrpc: XrpcServiceClient;
com: ComNS;
app: AppNS;
constructor(baseClient: Client, xrpcService: XrpcServiceClient);
setHeader(key: string, value: string): void;
}
export declare class ComNS {
_service: ServiceClient;
atproto: AtprotoNS;
constructor(service: ServiceClient);
}
export declare class AtprotoNS {
_service: ServiceClient;
account: AccountNS;
blob: BlobNS;
handle: HandleNS;
repo: RepoNS;
server: ServerNS;
session: SessionNS;
sync: SyncNS;
constructor(service: ServiceClient);
}
export declare class AccountNS {
_service: ServiceClient;
constructor(service: ServiceClient);
create(data?: ComAtprotoAccountCreate.InputSchema, opts?: ComAtprotoAccountCreate.CallOptions): Promise<ComAtprotoAccountCreate.Response>;
createInviteCode(data?: ComAtprotoAccountCreateInviteCode.InputSchema, opts?: ComAtprotoAccountCreateInviteCode.CallOptions): Promise<ComAtprotoAccountCreateInviteCode.Response>;
delete(data?: ComAtprotoAccountDelete.InputSchema, opts?: ComAtprotoAccountDelete.CallOptions): Promise<ComAtprotoAccountDelete.Response>;
get(params?: ComAtprotoAccountGet.QueryParams, opts?: ComAtprotoAccountGet.CallOptions): Promise<ComAtprotoAccountGet.Response>;
requestPasswordReset(data?: ComAtprotoAccountRequestPasswordReset.InputSchema, opts?: ComAtprotoAccountRequestPasswordReset.CallOptions): Promise<ComAtprotoAccountRequestPasswordReset.Response>;
resetPassword(data?: ComAtprotoAccountResetPassword.InputSchema, opts?: ComAtprotoAccountResetPassword.CallOptions): Promise<ComAtprotoAccountResetPassword.Response>;
}
export declare class BlobNS {
_service: ServiceClient;
constructor(service: ServiceClient);
upload(data?: ComAtprotoBlobUpload.InputSchema, opts?: ComAtprotoBlobUpload.CallOptions): Promise<ComAtprotoBlobUpload.Response>;
}
export declare class HandleNS {
_service: ServiceClient;
constructor(service: ServiceClient);
resolve(params?: ComAtprotoHandleResolve.QueryParams, opts?: ComAtprotoHandleResolve.CallOptions): Promise<ComAtprotoHandleResolve.Response>;
}
export declare class RepoNS {
_service: ServiceClient;
constructor(service: ServiceClient);
batchWrite(data?: ComAtprotoRepoBatchWrite.InputSchema, opts?: ComAtprotoRepoBatchWrite.CallOptions): Promise<ComAtprotoRepoBatchWrite.Response>;
createRecord(data?: ComAtprotoRepoCreateRecord.InputSchema, opts?: ComAtprotoRepoCreateRecord.CallOptions): Promise<ComAtprotoRepoCreateRecord.Response>;
deleteRecord(data?: ComAtprotoRepoDeleteRecord.InputSchema, opts?: ComAtprotoRepoDeleteRecord.CallOptions): Promise<ComAtprotoRepoDeleteRecord.Response>;
describe(params?: ComAtprotoRepoDescribe.QueryParams, opts?: ComAtprotoRepoDescribe.CallOptions): Promise<ComAtprotoRepoDescribe.Response>;
getRecord(params?: ComAtprotoRepoGetRecord.QueryParams, opts?: ComAtprotoRepoGetRecord.CallOptions): Promise<ComAtprotoRepoGetRecord.Response>;
listRecords(params?: ComAtprotoRepoListRecords.QueryParams, opts?: ComAtprotoRepoListRecords.CallOptions): Promise<ComAtprotoRepoListRecords.Response>;
putRecord(data?: ComAtprotoRepoPutRecord.InputSchema, opts?: ComAtprotoRepoPutRecord.CallOptions): Promise<ComAtprotoRepoPutRecord.Response>;
}
export declare class ServerNS {
_service: ServiceClient;
constructor(service: ServiceClient);
getAccountsConfig(params?: ComAtprotoServerGetAccountsConfig.QueryParams, opts?: ComAtprotoServerGetAccountsConfig.CallOptions): Promise<ComAtprotoServerGetAccountsConfig.Response>;
}
export declare class SessionNS {
_service: ServiceClient;
constructor(service: ServiceClient);
create(data?: ComAtprotoSessionCreate.InputSchema, opts?: ComAtprotoSessionCreate.CallOptions): Promise<ComAtprotoSessionCreate.Response>;
delete(data?: ComAtprotoSessionDelete.InputSchema, opts?: ComAtprotoSessionDelete.CallOptions): Promise<ComAtprotoSessionDelete.Response>;
get(params?: ComAtprotoSessionGet.QueryParams, opts?: ComAtprotoSessionGet.CallOptions): Promise<ComAtprotoSessionGet.Response>;
refresh(data?: ComAtprotoSessionRefresh.InputSchema, opts?: ComAtprotoSessionRefresh.CallOptions): Promise<ComAtprotoSessionRefresh.Response>;
}
export declare class SyncNS {
_service: ServiceClient;
constructor(service: ServiceClient);
getRepo(params?: ComAtprotoSyncGetRepo.QueryParams, opts?: ComAtprotoSyncGetRepo.CallOptions): Promise<ComAtprotoSyncGetRepo.Response>;
getRoot(params?: ComAtprotoSyncGetRoot.QueryParams, opts?: ComAtprotoSyncGetRoot.CallOptions): Promise<ComAtprotoSyncGetRoot.Response>;
updateRepo(data?: ComAtprotoSyncUpdateRepo.InputSchema, opts?: ComAtprotoSyncUpdateRepo.CallOptions): Promise<ComAtprotoSyncUpdateRepo.Response>;
}
export declare class AppNS {
_service: ServiceClient;
bsky: BskyNS;
constructor(service: ServiceClient);
}
export declare class BskyNS {
_service: ServiceClient;
actor: ActorNS;
feed: FeedNS;
graph: GraphNS;
notification: NotificationNS;
system: SystemNS;
constructor(service: ServiceClient);
}
export declare class ActorNS {
_service: ServiceClient;
profile: ProfileRecord;
constructor(service: ServiceClient);
createScene(data?: AppBskyActorCreateScene.InputSchema, opts?: AppBskyActorCreateScene.CallOptions): Promise<AppBskyActorCreateScene.Response>;
getProfile(params?: AppBskyActorGetProfile.QueryParams, opts?: AppBskyActorGetProfile.CallOptions): Promise<AppBskyActorGetProfile.Response>;
getSuggestions(params?: AppBskyActorGetSuggestions.QueryParams, opts?: AppBskyActorGetSuggestions.CallOptions): Promise<AppBskyActorGetSuggestions.Response>;
search(params?: AppBskyActorSearch.QueryParams, opts?: AppBskyActorSearch.CallOptions): Promise<AppBskyActorSearch.Response>;
searchTypeahead(params?: AppBskyActorSearchTypeahead.QueryParams, opts?: AppBskyActorSearchTypeahead.CallOptions): Promise<AppBskyActorSearchTypeahead.Response>;
updateProfile(data?: AppBskyActorUpdateProfile.InputSchema, opts?: AppBskyActorUpdateProfile.CallOptions): Promise<AppBskyActorUpdateProfile.Response>;
}
export declare class ProfileRecord {
_service: ServiceClient;
constructor(service: ServiceClient);
list(params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>): Promise<{
cursor?: string;
records: {
uri: string;
value: AppBskyActorProfile.Record;
}[];
}>;
get(params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>): Promise<{
uri: string;
cid: string;
value: AppBskyActorProfile.Record;
}>;
create(params: Omit<ComAtprotoRepoCreateRecord.InputSchema, 'collection' | 'record'>, record: AppBskyActorProfile.Record, headers?: Record<string, string>): Promise<{
uri: string;
cid: string;
}>;
delete(params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, headers?: Record<string, string>): Promise<void>;
}
export declare class FeedNS {
_service: ServiceClient;
post: PostRecord;
repost: RepostRecord;
trend: TrendRecord;
vote: VoteRecord;
constructor(service: ServiceClient);
getAuthorFeed(params?: AppBskyFeedGetAuthorFeed.QueryParams, opts?: AppBskyFeedGetAuthorFeed.CallOptions): Promise<AppBskyFeedGetAuthorFeed.Response>;
getPostThread(params?: AppBskyFeedGetPostThread.QueryParams, opts?: AppBskyFeedGetPostThread.CallOptions): Promise<AppBskyFeedGetPostThread.Response>;
getRepostedBy(params?: AppBskyFeedGetRepostedBy.QueryParams, opts?: AppBskyFeedGetRepostedBy.CallOptions): Promise<AppBskyFeedGetRepostedBy.Response>;
getTimeline(params?: AppBskyFeedGetTimeline.QueryParams, opts?: AppBskyFeedGetTimeline.CallOptions): Promise<AppBskyFeedGetTimeline.Response>;
getVotes(params?: AppBskyFeedGetVotes.QueryParams, opts?: AppBskyFeedGetVotes.CallOptions): Promise<AppBskyFeedGetVotes.Response>;
setVote(data?: AppBskyFeedSetVote.InputSchema, opts?: AppBskyFeedSetVote.CallOptions): Promise<AppBskyFeedSetVote.Response>;
}
export declare class PostRecord {
_service: ServiceClient;
constructor(service: ServiceClient);
list(params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>): Promise<{
cursor?: string;
records: {
uri: string;
value: AppBskyFeedPost.Record;
}[];
}>;
get(params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>): Promise<{
uri: string;
cid: string;
value: AppBskyFeedPost.Record;
}>;
create(params: Omit<ComAtprotoRepoCreateRecord.InputSchema, 'collection' | 'record'>, record: AppBskyFeedPost.Record, headers?: Record<string, string>): Promise<{
uri: string;
cid: string;
}>;
delete(params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, headers?: Record<string, string>): Promise<void>;
}
export declare class RepostRecord {
_service: ServiceClient;
constructor(service: ServiceClient);
list(params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>): Promise<{
cursor?: string;
records: {
uri: string;
value: AppBskyFeedRepost.Record;
}[];
}>;
get(params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>): Promise<{
uri: string;
cid: string;
value: AppBskyFeedRepost.Record;
}>;
create(params: Omit<ComAtprotoRepoCreateRecord.InputSchema, 'collection' | 'record'>, record: AppBskyFeedRepost.Record, headers?: Record<string, string>): Promise<{
uri: string;
cid: string;
}>;
delete(params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, headers?: Record<string, string>): Promise<void>;
}
export declare class TrendRecord {
_service: ServiceClient;
constructor(service: ServiceClient);
list(params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>): Promise<{
cursor?: string;
records: {
uri: string;
value: AppBskyFeedTrend.Record;
}[];
}>;
get(params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>): Promise<{
uri: string;
cid: string;
value: AppBskyFeedTrend.Record;
}>;
create(params: Omit<ComAtprotoRepoCreateRecord.InputSchema, 'collection' | 'record'>, record: AppBskyFeedTrend.Record, headers?: Record<string, string>): Promise<{
uri: string;
cid: string;
}>;
delete(params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, headers?: Record<string, string>): Promise<void>;
}
export declare class VoteRecord {
_service: ServiceClient;
constructor(service: ServiceClient);
list(params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>): Promise<{
cursor?: string;
records: {
uri: string;
value: AppBskyFeedVote.Record;
}[];
}>;
get(params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>): Promise<{
uri: string;
cid: string;
value: AppBskyFeedVote.Record;
}>;
create(params: Omit<ComAtprotoRepoCreateRecord.InputSchema, 'collection' | 'record'>, record: AppBskyFeedVote.Record, headers?: Record<string, string>): Promise<{
uri: string;
cid: string;
}>;
delete(params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, headers?: Record<string, string>): Promise<void>;
}
export declare class GraphNS {
_service: ServiceClient;
assertion: AssertionRecord;
confirmation: ConfirmationRecord;
follow: FollowRecord;
constructor(service: ServiceClient);
getAssertions(params?: AppBskyGraphGetAssertions.QueryParams, opts?: AppBskyGraphGetAssertions.CallOptions): Promise<AppBskyGraphGetAssertions.Response>;
getFollowers(params?: AppBskyGraphGetFollowers.QueryParams, opts?: AppBskyGraphGetFollowers.CallOptions): Promise<AppBskyGraphGetFollowers.Response>;
getFollows(params?: AppBskyGraphGetFollows.QueryParams, opts?: AppBskyGraphGetFollows.CallOptions): Promise<AppBskyGraphGetFollows.Response>;
getMembers(params?: AppBskyGraphGetMembers.QueryParams, opts?: AppBskyGraphGetMembers.CallOptions): Promise<AppBskyGraphGetMembers.Response>;
getMemberships(params?: AppBskyGraphGetMemberships.QueryParams, opts?: AppBskyGraphGetMemberships.CallOptions): Promise<AppBskyGraphGetMemberships.Response>;
}
export declare class AssertionRecord {
_service: ServiceClient;
constructor(service: ServiceClient);
list(params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>): Promise<{
cursor?: string;
records: {
uri: string;
value: AppBskyGraphAssertion.Record;
}[];
}>;
get(params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>): Promise<{
uri: string;
cid: string;
value: AppBskyGraphAssertion.Record;
}>;
create(params: Omit<ComAtprotoRepoCreateRecord.InputSchema, 'collection' | 'record'>, record: AppBskyGraphAssertion.Record, headers?: Record<string, string>): Promise<{
uri: string;
cid: string;
}>;
delete(params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, headers?: Record<string, string>): Promise<void>;
}
export declare class ConfirmationRecord {
_service: ServiceClient;
constructor(service: ServiceClient);
list(params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>): Promise<{
cursor?: string;
records: {
uri: string;
value: AppBskyGraphConfirmation.Record;
}[];
}>;
get(params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>): Promise<{
uri: string;
cid: string;
value: AppBskyGraphConfirmation.Record;
}>;
create(params: Omit<ComAtprotoRepoCreateRecord.InputSchema, 'collection' | 'record'>, record: AppBskyGraphConfirmation.Record, headers?: Record<string, string>): Promise<{
uri: string;
cid: string;
}>;
delete(params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, headers?: Record<string, string>): Promise<void>;
}
export declare class FollowRecord {
_service: ServiceClient;
constructor(service: ServiceClient);
list(params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>): Promise<{
cursor?: string;
records: {
uri: string;
value: AppBskyGraphFollow.Record;
}[];
}>;
get(params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>): Promise<{
uri: string;
cid: string;
value: AppBskyGraphFollow.Record;
}>;
create(params: Omit<ComAtprotoRepoCreateRecord.InputSchema, 'collection' | 'record'>, record: AppBskyGraphFollow.Record, headers?: Record<string, string>): Promise<{
uri: string;
cid: string;
}>;
delete(params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, headers?: Record<string, string>): Promise<void>;
}
export declare class NotificationNS {
_service: ServiceClient;
constructor(service: ServiceClient);
getCount(params?: AppBskyNotificationGetCount.QueryParams, opts?: AppBskyNotificationGetCount.CallOptions): Promise<AppBskyNotificationGetCount.Response>;
list(params?: AppBskyNotificationList.QueryParams, opts?: AppBskyNotificationList.CallOptions): Promise<AppBskyNotificationList.Response>;
updateSeen(data?: AppBskyNotificationUpdateSeen.InputSchema, opts?: AppBskyNotificationUpdateSeen.CallOptions): Promise<AppBskyNotificationUpdateSeen.Response>;
}
export declare class SystemNS {
_service: ServiceClient;
declaration: DeclarationRecord;
constructor(service: ServiceClient);
}
export declare class DeclarationRecord {
_service: ServiceClient;
constructor(service: ServiceClient);
list(params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>): Promise<{
cursor?: string;
records: {
uri: string;
value: AppBskySystemDeclaration.Record;
}[];
}>;
get(params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>): Promise<{
uri: string;
cid: string;
value: AppBskySystemDeclaration.Record;
}>;
create(params: Omit<ComAtprotoRepoCreateRecord.InputSchema, 'collection' | 'record'>, record: AppBskySystemDeclaration.Record, headers?: Record<string, string>): Promise<{
uri: string;
cid: string;
}>;
delete(params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, headers?: Record<string, string>): Promise<void>;
}
-64
View File
@@ -1,64 +0,0 @@
import { LexiconDoc } from '@atproto/lexicon';
export declare const lexicons: LexiconDoc[];
export declare const ids: {
ComAtprotoAccountCreate: string;
ComAtprotoAccountCreateInviteCode: string;
ComAtprotoAccountDelete: string;
ComAtprotoAccountGet: string;
ComAtprotoAccountRequestPasswordReset: string;
ComAtprotoAccountResetPassword: string;
ComAtprotoBlobUpload: string;
ComAtprotoHandleResolve: string;
ComAtprotoRepoBatchWrite: string;
ComAtprotoRepoCreateRecord: string;
ComAtprotoRepoDeleteRecord: string;
ComAtprotoRepoDescribe: string;
ComAtprotoRepoGetRecord: string;
ComAtprotoRepoListRecords: string;
ComAtprotoRepoPutRecord: string;
ComAtprotoRepoStrongRef: string;
ComAtprotoServerGetAccountsConfig: string;
ComAtprotoSessionCreate: string;
ComAtprotoSessionDelete: string;
ComAtprotoSessionGet: string;
ComAtprotoSessionRefresh: string;
ComAtprotoSyncGetRepo: string;
ComAtprotoSyncGetRoot: string;
ComAtprotoSyncUpdateRepo: string;
AppBskyActorCreateScene: string;
AppBskyActorGetProfile: string;
AppBskyActorGetSuggestions: string;
AppBskyActorProfile: string;
AppBskyActorRef: string;
AppBskyActorSearch: string;
AppBskyActorSearchTypeahead: string;
AppBskyActorUpdateProfile: string;
AppBskyFeedEmbed: string;
AppBskyFeedGetAuthorFeed: string;
AppBskyFeedGetPostThread: string;
AppBskyFeedGetRepostedBy: string;
AppBskyFeedGetTimeline: string;
AppBskyFeedGetVotes: string;
AppBskyFeedPost: string;
AppBskyFeedRepost: string;
AppBskyFeedSetVote: string;
AppBskyFeedTrend: string;
AppBskyFeedVote: string;
AppBskyGraphAssertCreator: string;
AppBskyGraphAssertMember: string;
AppBskyGraphAssertion: string;
AppBskyGraphConfirmation: string;
AppBskyGraphFollow: string;
AppBskyGraphGetAssertions: string;
AppBskyGraphGetFollowers: string;
AppBskyGraphGetFollows: string;
AppBskyGraphGetMembers: string;
AppBskyGraphGetMemberships: string;
AppBskyNotificationGetCount: string;
AppBskyNotificationList: string;
AppBskyNotificationUpdateSeen: string;
AppBskySystemActorScene: string;
AppBskySystemActorUser: string;
AppBskySystemDeclRef: string;
AppBskySystemDeclaration: string;
};
-17
View File
@@ -1,17 +0,0 @@
import { MethodSchema, RecordSchema } from '@atproto/lexicon';
export declare const methodSchemaDict: Record<string, MethodSchema>;
export declare const methodSchemas: MethodSchema[];
export declare const ids: {
AppBskyActorProfile: string;
AppBskyFeedMediaEmbed: string;
AppBskyFeedPost: string;
AppBskyFeedRepost: string;
AppBskyFeedTrend: string;
AppBskyFeedVote: string;
AppBskyGraphAssertion: string;
AppBskyGraphConfirmation: string;
AppBskyGraphFollow: string;
AppBskySystemDeclaration: string;
};
export declare const recordSchemaDict: Record<string, RecordSchema>;
export declare const recordSchemas: RecordSchema[];
@@ -1,32 +0,0 @@
import { Headers, XRPCError } from '@atproto/xrpc';
import * as AppBskySystemDeclRef from '../system/declRef';
export interface QueryParams {
}
export interface InputSchema {
handle: string;
recoveryKey?: string;
[k: string]: unknown;
}
export interface OutputSchema {
handle: string;
did: string;
declaration: AppBskySystemDeclRef.Main;
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
qp?: QueryParams;
encoding: 'application/json';
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare class InvalidHandleError extends XRPCError {
constructor(src: XRPCError);
}
export declare class HandleNotAvailableError extends XRPCError {
constructor(src: XRPCError);
}
export declare function toKnownErr(e: any): any;
@@ -1,35 +0,0 @@
import { Headers } from '@atproto/xrpc';
import * as AppBskySystemDeclRef from '../system/declRef';
export interface QueryParams {
actor: string;
}
export declare type InputSchema = undefined;
export interface OutputSchema {
did: string;
declaration: AppBskySystemDeclRef.Main;
handle: string;
creator: string;
displayName?: string;
description?: string;
avatar?: string;
followersCount: number;
followsCount: number;
membersCount: number;
postsCount: number;
myState?: MyState;
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare function toKnownErr(e: any): any;
export interface MyState {
follow?: string;
member?: string;
[k: string]: unknown;
}
@@ -1,36 +0,0 @@
import { Headers } from '@atproto/xrpc';
import * as AppBskySystemDeclRef from '../system/declRef';
export interface QueryParams {
limit?: number;
cursor?: string;
}
export declare type InputSchema = undefined;
export interface OutputSchema {
cursor?: string;
actors: Actor[];
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare function toKnownErr(e: any): any;
export interface Actor {
did: string;
declaration: AppBskySystemDeclRef.Main;
handle: string;
displayName?: string;
description?: string;
avatar?: string;
indexedAt?: string;
myState?: MyState;
[k: string]: unknown;
}
export interface MyState {
follow?: string;
[k: string]: unknown;
}
@@ -1,10 +0,0 @@
export interface Record {
displayName: string;
description?: string;
avatar?: {
cid: string;
mimeType: string;
[k: string]: unknown;
};
[k: string]: unknown;
}
@@ -1,14 +0,0 @@
import * as AppBskySystemDeclRef from '../system/declRef';
export interface Main {
did: string;
declarationCid: string;
[k: string]: unknown;
}
export interface WithInfo {
did: string;
declaration: AppBskySystemDeclRef.Main;
handle: string;
displayName?: string;
avatar?: string;
[k: string]: unknown;
}
@@ -1,32 +0,0 @@
import { Headers } from '@atproto/xrpc';
import * as AppBskySystemDeclRef from '../system/declRef';
export interface QueryParams {
term: string;
limit?: number;
before?: string;
}
export declare type InputSchema = undefined;
export interface OutputSchema {
cursor?: string;
users: User[];
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare function toKnownErr(e: any): any;
export interface User {
did: string;
declaration: AppBskySystemDeclRef.Main;
handle: string;
displayName?: string;
avatar?: string;
description?: string;
indexedAt?: string;
[k: string]: unknown;
}
@@ -1,28 +0,0 @@
import { Headers } from '@atproto/xrpc';
import * as AppBskySystemDeclRef from '../system/declRef';
export interface QueryParams {
term: string;
limit?: number;
}
export declare type InputSchema = undefined;
export interface OutputSchema {
users: User[];
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare function toKnownErr(e: any): any;
export interface User {
did: string;
declaration: AppBskySystemDeclRef.Main;
handle: string;
displayName?: string;
avatar?: string;
[k: string]: unknown;
}
@@ -1,31 +0,0 @@
import { Headers } from '@atproto/xrpc';
export interface QueryParams {
}
export interface InputSchema {
did?: string;
displayName?: string;
description?: string;
avatar?: {
cid: string;
mimeType: string;
[k: string]: unknown;
};
[k: string]: unknown;
}
export interface OutputSchema {
uri: string;
cid: string;
record: {};
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
qp?: QueryParams;
encoding: 'application/json';
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare function toKnownErr(e: any): any;
@@ -1,57 +0,0 @@
export interface Record {
text: string;
entities?: Entity[];
reply?: Reply;
createdAt: string;
[k: string]: unknown;
}
export declare const SOMETOKEN = "app.bsky.feed.debug#someToken";
export declare type Unknown = {};
export declare type Boolean = boolean;
export declare type Number = number;
export declare type Integer = number;
export declare type String = string;
export declare type Blob = {
cid: string;
mimeType: string;
[k: string]: unknown;
};
export declare type Image = {
cid: string;
mimeType: string;
[k: string]: unknown;
};
export declare type Video = {
cid: string;
mimeType: string;
[k: string]: unknown;
};
export declare type Audio = {
cid: string;
mimeType: string;
[k: string]: unknown;
};
export declare type StringArr = string[];
export declare type ReplyArr = Reply[];
export declare type MultiArr = (Reply | PostRef | Entity)[];
export interface Reply {
root: PostRef;
parent: PostRef;
[k: string]: unknown;
}
export interface PostRef {
uri: string;
cid: string;
[k: string]: unknown;
}
export interface Entity {
index: TextSlice;
type: string;
value: string;
[k: string]: unknown;
}
export interface TextSlice {
start: number;
end: number;
[k: string]: unknown;
}
@@ -1,36 +0,0 @@
import * as AppBskyActorRef from '../actor/ref';
export interface Main {
items?: (Media | Record | External | {
$type: string;
[k: string]: unknown;
})[];
[k: string]: unknown;
}
export interface Media {
alt?: string;
thumb?: {
cid: string;
mimeType: string;
[k: string]: unknown;
};
original: {
cid: string;
mimeType: string;
[k: string]: unknown;
};
[k: string]: unknown;
}
export interface Record {
type: 'record';
author: AppBskyActorRef.WithInfo;
record: {};
[k: string]: unknown;
}
export interface External {
type: 'external';
uri: string;
title: string;
description: string;
imageUri: string;
[k: string]: unknown;
}
@@ -1,45 +0,0 @@
import { Headers } from '@atproto/xrpc';
import * as AppBskyActorRef from '../actor/ref';
import * as AppBskyFeedEmbed from './embed';
export interface QueryParams {
author: string;
limit?: number;
before?: string;
}
export declare type InputSchema = undefined;
export interface OutputSchema {
cursor?: string;
feed: FeedItem[];
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare function toKnownErr(e: any): any;
export interface FeedItem {
uri: string;
cid: string;
author: AppBskyActorRef.WithInfo;
trendedBy?: AppBskyActorRef.WithInfo;
repostedBy?: AppBskyActorRef.WithInfo;
record: {};
embed?: AppBskyFeedEmbed.Main;
replyCount: number;
repostCount: number;
upvoteCount: number;
downvoteCount: number;
indexedAt: string;
myState?: MyState;
[k: string]: unknown;
}
export interface MyState {
repost?: string;
upvote?: string;
downvote?: string;
[k: string]: unknown;
}
@@ -1,29 +0,0 @@
import { Headers } from '@atproto/xrpc';
export interface QueryParams {
uri: string;
cid?: string;
limit?: number;
before?: string;
}
export interface CallOptions {
headers?: Headers;
}
export declare type InputSchema = undefined;
export interface OutputSchema {
uri: string;
cid?: string;
cursor?: string;
likedBy: {
did: string;
handle: string;
displayName?: string;
createdAt?: string;
indexedAt: string;
}[];
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare function toKnownErr(e: any): any;
@@ -1,60 +0,0 @@
import { Headers, XRPCError } from '@atproto/xrpc';
import * as AppBskyActorRef from '../actor/ref';
import * as AppBskyFeedEmbed from './embed';
export interface QueryParams {
uri: string;
depth?: number;
}
export declare type InputSchema = undefined;
export interface OutputSchema {
thread: Post | NotFoundPost | {
$type: string;
[k: string]: unknown;
};
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare class NotFoundError extends XRPCError {
constructor(src: XRPCError);
}
export declare function toKnownErr(e: any): any;
export interface Post {
uri: string;
cid: string;
author: AppBskyActorRef.WithInfo;
record: {};
embed?: AppBskyFeedEmbed.Main;
parent?: Post | NotFoundPost | {
$type: string;
[k: string]: unknown;
};
replyCount: number;
replies?: (Post | NotFoundPost | {
$type: string;
[k: string]: unknown;
})[];
repostCount: number;
upvoteCount: number;
downvoteCount: number;
indexedAt: string;
myState?: MyState;
[k: string]: unknown;
}
export interface NotFoundPost {
uri: string;
notFound: true;
[k: string]: unknown;
}
export interface MyState {
repost?: string;
upvote?: string;
downvote?: string;
[k: string]: unknown;
}
@@ -1,35 +0,0 @@
import { Headers } from '@atproto/xrpc';
import * as AppBskySystemDeclRef from '../system/declRef';
export interface QueryParams {
uri: string;
cid?: string;
limit?: number;
before?: string;
}
export declare type InputSchema = undefined;
export interface OutputSchema {
uri: string;
cid?: string;
cursor?: string;
repostedBy: RepostedBy[];
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare function toKnownErr(e: any): any;
export interface RepostedBy {
did: string;
declaration: AppBskySystemDeclRef.Main;
handle: string;
displayName?: string;
avatar?: string;
createdAt?: string;
indexedAt: string;
[k: string]: unknown;
}
@@ -1,45 +0,0 @@
import { Headers } from '@atproto/xrpc';
import * as AppBskyActorRef from '../actor/ref';
import * as AppBskyFeedEmbed from './embed';
export interface QueryParams {
algorithm?: string;
limit?: number;
before?: string;
}
export declare type InputSchema = undefined;
export interface OutputSchema {
cursor?: string;
feed: FeedItem[];
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare function toKnownErr(e: any): any;
export interface FeedItem {
uri: string;
cid: string;
author: AppBskyActorRef.WithInfo;
trendedBy?: AppBskyActorRef.WithInfo;
repostedBy?: AppBskyActorRef.WithInfo;
record: {};
embed?: AppBskyFeedEmbed.Main;
replyCount: number;
repostCount: number;
upvoteCount: number;
downvoteCount: number;
indexedAt: string;
myState?: MyState;
[k: string]: unknown;
}
export interface MyState {
repost?: string;
upvote?: string;
downvote?: string;
[k: string]: unknown;
}
@@ -1,33 +0,0 @@
import { Headers } from '@atproto/xrpc';
import * as AppBskyActorRef from '../actor/ref';
export interface QueryParams {
uri: string;
cid?: string;
direction?: 'up' | 'down';
limit?: number;
before?: string;
}
export declare type InputSchema = undefined;
export interface OutputSchema {
uri: string;
cid?: string;
cursor?: string;
votes: Vote[];
[k: string]: unknown;
}
export interface CallOptions {
headers?: Headers;
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export declare function toKnownErr(e: any): any;
export interface Vote {
direction: 'up' | 'down';
indexedAt: string;
createdAt: string;
actor: AppBskyActorRef.WithInfo;
[k: string]: unknown;
}
@@ -1,10 +0,0 @@
export interface Record {
subject: Subject;
createdAt: string;
[k: string]: unknown;
}
export interface Subject {
uri: string;
cid: string;
[k: string]: unknown;
}
@@ -1,18 +0,0 @@
export interface Main {
media: MediaEmbed[];
[k: string]: unknown;
}
export interface MediaEmbed {
alt?: string;
thumb?: {
cid: string;
mimeType: string;
[k: string]: unknown;
};
original: {
cid: string;
mimeType: string;
[k: string]: unknown;
};
[k: string]: unknown;
}
@@ -1,24 +0,0 @@
import * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef';
export interface Record {
text: string;
entities?: Entity[];
reply?: ReplyRef;
createdAt: string;
[k: string]: unknown;
}
export interface ReplyRef {
root: ComAtprotoRepoStrongRef.Main;
parent: ComAtprotoRepoStrongRef.Main;
[k: string]: unknown;
}
export interface Entity {
index: TextSlice;
type: string;
value: string;
[k: string]: unknown;
}
export interface TextSlice {
start: number;
end: number;
[k: string]: unknown;
}
@@ -1,6 +0,0 @@
import * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef';
export interface Record {
subject: ComAtprotoRepoStrongRef.Main;
createdAt: string;
[k: string]: unknown;
}

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