Merge main into the Web PR (#230)

* Update to RN 71.1.0 (#100)

* Update to RN 71

* Adds missing lint plugin

* Add missing native changes

* Bump @atproto/api@0.0.7 (#112)

* Image not loading on swipe (#114)

* Adds prefetching to images

* Adds image prefetch

* bugfix for images not showing on swipe

* Fixes prefetch bug

* Update src/view/com/util/PostEmbeds.tsx

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Fixes to session management (#117)

* Update session-management to solve incorrectly dropped sessions

* Reset the nav on account switch

* Reset the feed on me.load()

* Update tests to reflect new account-switching behavior

* Increase max image resolutions and sizes (#118)

* Slightly increase the hitslop for post controls

* Fix character counter color in dark mode

* Update login to use new session.create api, which enables email login (close #93) (#119)

* Replaces the alert with dropdown for profile image and banner (#123)

* replaces the alert with dropdown for profile image and banner

* lint

* Fix to ordering of images in the embed grid (#121)

* Add explicit link-embed controls to the composer (#120)

* Add explicit link-embed controls

* Update the target rez/size of link embed thumbs

* Remove the alert before publishing without a link card

* [Draft] Fixes image failing on reupload issue (#128)

* Fixes image failing on reupload issue

* Use tmp folder instead of documents

* lint

* Image performance improvements (#126)

* Switch out most images for FastImage

* Add image loading placeholders

* Fix tests

* Collection of fixes to list rendering (#127)

* Fix bug that caused endless spinners in profile feeds

* Bundle fetches of suggested actors into one update

* Fixes to suggested follow rendering

* Fix missing replacement of flex:1 to height:100

* Fixes to navigation swipes (#129)

* Nav swipe: increase the distance traveled in response to gesture movement.

This causes swipes to feel faster and more responsive.

* Fix: fully clamp the swipe against the edge

* Improve the performance of swipes by skipping the interaction manager

* Adds dark mode to the edit screen (#130)

* Adds dark mode to edit screen

* lint

* lint

* lint

* Reduce render cost of post controls and improve perceived responsiveness (#132)

* Move post control animations into conditional render and increase perceived responsiveness

* Remove log

* Adds dark mode to the dropdown (#131)

* Adds dark mode to the bottom sheet

* Make background button lighter (like before)

* lint

* Fix bug in lightbox rendering (#133)

* Fix layout in onboarding to not overflow the footer

* Configure feed FlatList (removeClippedSubviews=true) to improve scroll performance (#136)

* Disable like/repost animations to see if theyre causing #135 (#137)

* Composer: mention tagging now works in middle of text (close #105) (#139)

* Implement account deletion (#141)

* Fix photo & camera permission management (#140)

* Check photo & camera perms and alert the user if not available (close #64)

- Adds perms checks with a prompt to update settings if needed
- Moves initial access of photos in the composer so that the initial prompt
  occurs at an intuitive time.

* Add react-native-permissions test mock

* Fix issue causing multiple access requests

* Use longer var names

* Update podfile.lock

* Lint fix

* Move photo perm request in composer to the gallery btn instead of when the carousel is opened

* Adds more tracking all around the app (#142)

* Adds more tracking all around the app

* more events

* lint

* using better analytics naming

* missed file

* more fixes

* Calculate image aspect ratio on load (#146)

* Calculate image aspect ratio on load

* Move aspect ratio bounds to constants

* Adds detox testing and instructions (#147)

* Adds detox testing and instructions

* lint

* lint

* Error cleanup (close #79) (#148)

* Avoid surfacing errors to the user when it's not critical

* Remove now-unused GetAssertionsView

* Apply cleanError() consistently

* Give a better error message for Upstream Failures (http status 502)

* Hide errors in notifications because they're not useful

* More e2e tests (create account) (#150)

* Adds respots under the 'post' tab under profile (#158)

* Adds dark mode to delete account screen (#159)

* 87 dark mode edit profile (#162)

* Adds dark mode to delete account screen

* Adds one more missed darkmode

* more fixes

* Remove fallback gradient on external links without thumbs (#164)

* Remove fallback gradient on external links without thumbs

* Remove fallback gradient on external links without thumbs in the composer preview

* Fix refresh behavior around a series of models (repost, graph, vote) (#163)

* Fix refresh behavior around a series of models (repost, graph, vote)

* Fix cursor behavior in reposted-by view

* Fixes issue where retrying on image upload fails (#166)

* Fixes issue where retrying on image upload fails

* Lint, longer test time

* Longer waitfor time in tests

* even longer timeout

* longer timeout

* missed file

* Update src/view/com/composer/ComposePost.tsx

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Update src/view/com/composer/ComposePost.tsx

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* 154 cached image profile (#167)

* Fixes issue where retrying on image upload fails

* Lint, longer test time

* Longer waitfor time in tests

* even longer timeout

* longer timeout

* missed file

* Fixes image cache error on second try for profile screen

* lint

* lint

* lint

* Refactor session management to use a new "Agent" API (#165)

* Add the atp-agent implementation (temporarily in this repo)

* Rewrite all session & API management to use the new atp-agent

* Update tests for the atp-agent refactor

* Refactor management of session-related state. Includes:
- More careful management of when state is cleared or fetched
- Debug logging to help trace future issues
- Clearer APIs overall

* Bubble session-expiration events to the user and display a toast to explain

* Switch to the new @atproto/api@0.1.0

* Minor aesthetic cleanup in SessionModel

* Wire up ReportAccount and ReportPost (#168)

* Fixes embeds for youtube channels (#169)

* Bump app ios version to 1.1 (needed after app store submission)

* Fix potential issues with promise guards when an error occurs (#170)

* Refactor models to use bundleAsync and lock regions (#171)

* Fix to an edge case with feed re-ordering for threads (#172)

* 151 fix youtube channel embed (#173)

* Fixes embeds for youtube channels

* Tests for youtube extract meta

* lint

* Add 'doesnt use non-exempt encryption' to ios config

* Rework the search UI and add  (#174)

* Add search tab and move icon to footer

* Remove subtitles from view header

* Remove unused code

* Clean up UI of search screen

* Search: give better user feedback to UI state and add a cancel button

* Add WhoToFollow section to search

* Add a temporary SuggestedPosts solution using the patented 'bsky team algo'

* Trigger reload of suggested content in search on open

* Wait five min between reloading discovery content

* Reduce weight of solid search icon in footer

* Fix lint

* Fix tests

* 151 feat youtube embed iframe (#176)

* youtube embed iframe temp commit

* Fixes styling and code cleanup

* lint

* Now clicking between the pause and settings button doesn't trigger the parent

* use modest branding (less yt logos)

* Stop playing the video once there's a navigation event

* Make sure the iframe is unmounted on any navigation event

* fixes tests

* lint

* Add scroll-to-top for all screens (#177)

* Adds hardcoded suggested list (#178)

* Adds hardcoded suggested list

* Update suggested-actors-view to support page sizes smaller than the hardcoded list

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* more robust centering of the play button (#181)

Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>

* Bundle of UI modifications (#175)

* Adjust visual balance of SuggestedPosts and WhoToFollow

* Fix bug in the discovery load trigger

* Adjust search header aesthetic and have it scroll away

* More visual balance tweaks on the search page

* Even more visual balance tweaks on the search page

* Hide the footer on scroll in search

* Ditch the composer prompt buttons in the home feed

* Center the view header title

* Hide header on scroll on the home feed

* Fix e2e tests

* Fix home feed positioning (closes #189) (#195)

* Fix home feed positioning for floating header

* Fix positioning of errors in home feed

* Fix lint

* Don't show new-content notification for reposts (close #179) (#197)

* Show the splash screen during session resumption (close #186) (#199)

* Fix to suggested follows: chunk the hardcoded fetches to 25 at a time (close #196) (#198)

* UI updates to the floating action button (#201)

* Update FAB to use a plus icon and not drop shadow

* Update FAB positioning to be more consistent in different shell modes

* Animate the FAB's repositioning

* Remove the 'loading' placeholder from images as it degraded feed perf (#202)

* Remove the 'loading' placeholder from images as it degraded feed perf

* Remove references

* Fix RN bug that causes home feed not to load more; also fix home feed load view. (#208)

RN has a bug where rendering a flatlist with an empty array appears to break its
virtual list windowing behaviors. See https://stackoverflow.com/a/67873596

* Only give the loading spinner on the home feed during PTR (#207)

(cherry picked from commit b7a5da12fdfacef74873b5cf6d75f20d259bde0e)

* Implement our own lifecycle tracking to ensure it never fires while the app is backgrounded (close #193) (#211)

* Push notification fixes (#210)

* Fix to when screen analytics events are firing

* Fix: dont trigger update state when backgrounded

* Small fix to notifee API usage

* Fix: properly load notification info for push card

* Add feedback link to main menu (close #191) (#212)

* Add "follows you" information and sync follow state between views (#215)

* Bump @atproto/api@0.1.2 and update API usage

* Add 'follows you' pill to profile header (close #110)

* Add 'follows you' to followers and follows (close #103)

* Update reposted-by and liked-by views to use the same components as followers and following

* Create a local follows cache MyFollowsModel to keep views in sync (close #205)

* Add incremental hydration to the MyFollows model

* Fix tests

* Update deps

* Fix lint

* Fix to paginated fetches

* Fix reference

* Fix potential state-desync issue

* Fixes to notifications (#216)

* Improve push-notification for follows

* Refresh notifications on screen open (close #214)

* Avoid showing loader more than needed in post threads

* Refactor notification polling to handle view-state more effectively

* Delete a bunch of tests taht werent adding value

* Remove the accounts integration test; we'll use the e2e test instead

* Load latest in notifications when the screen is open rather than full refresh

* Randomize hard-coded suggested follows (#226)

* Ensure follows are loaded before filtering hardcoded suggestions

* Randomize hard-coded suggested profiles (close #219)

* Sanitizes posts on publish and render (#217)

* Sanatizes posts on publish and render

* lint

* lint and added sanitize to thread view as well

* adjusts indices based on replaced text

* Woops, fixes a bug

* bugfix + cleanup

* comment

* lint

* move sanitize text to later in the flow

* undo changes to compose post

* Add RichText library building upon the sanitizePost library method

* Add lodash.clonedeep dep

* Switch to RichText processing on record load & render

* Fix lint

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* A group of notifications fixes (#227)

* Fix: don't group together notifications that can't visually be grouped (close #221)

* Mark all notifications read on PTR

* Small optimization: useCallback and useMemo in posts feed

* Add loading spinner to footer of notifications (close #222)

* Fix to scrolling to posts within a thread (#228)

* Fix: render the entire thread at start so that scrollToIndex works always (close #270)

* Visual fixes to thread 'load more'

* A few small perf improvements to thread rendering

* Fix lint

* 1.2

* Remove unused logger lib

* Remove state-mock

* Type fixes

* Reorganize the folder structure for lib and switch to typescript path aliases

* Move build-flags into lib

* Move to the state path alias

* Add view path alias

* Fix lint

* iOS build fixes

* Wrap analytics in native/web splitter and re-enable in all view code

* Add web version of react-native-webview

* Add web split for version number

* Fix BlurView import for web

* Add web split for fastimage

* Create web split for permissions lib

* Fix for web high priority images

---------

Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>
zio/stable
Paul Frazee 2023-02-22 14:23:57 -06:00 committed by GitHub
parent 7916b26aad
commit f28334739b
242 changed files with 8400 additions and 7454 deletions

85
.detoxrc.js 100644
View File

@ -0,0 +1,85 @@
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: {
$0: 'jest',
config: 'e2e/jest.config.js',
},
jest: {
setupTimeout: 120000,
},
},
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/app.app',
build:
'xcodebuild -workspace ios/app.xcworkspace -scheme app -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
},
'ios.release': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/app.app',
build:
'xcodebuild -workspace ios/app.xcworkspace -scheme app -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
build:
'cd android ; ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug ; cd -',
reversePorts: [8081],
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
build:
'cd android ; ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release ; cd -',
},
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 14',
},
},
attached: {
type: 'android.attached',
device: {
adbName: '.*',
},
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_3a_API_30_x86',
},
},
},
configurations: {
'ios.sim.debug': {
device: 'simulator',
app: 'ios.debug',
},
'ios.sim.release': {
device: 'simulator',
app: 'ios.release',
},
'android.att.debug': {
device: 'attached',
app: 'android.debug',
},
'android.att.release': {
device: 'attached',
app: 'android.release',
},
'android.emu.debug': {
device: 'emulator',
app: 'android.debug',
},
'android.emu.release': {
device: 'emulator',
app: 'android.release',
},
},
}

View File

@ -2,7 +2,7 @@ module.exports = {
root: true,
extends: '@react-native-community',
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
plugins: ['@typescript-eslint', 'detox'],
ignorePatterns: [
'**/__mocks__/*.ts',
'src/third-party',

View File

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Yarn install
run: yarn
- name: Lint Reporter
@ -30,7 +30,7 @@ jobs:
with:
node-version: 18
- name: Check out Git repository
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Yarn install
run: yarn
- name: Run tests

1
.gitignore vendored
View File

@ -63,3 +63,4 @@ buck-out/
# Testing
coverage/
junit.xml
artifacts

View File

@ -1,18 +1,12 @@
# Social App
In-progress social app.
Uses:
- [React Native](https://reactnative.dev)
- [React Native for Web](https://necolas.github.io/react-native-web/)
- [React Navigation](https://reactnative.dev/docs/navigation#react-navigation)
- [MobX](https://mobx.js.org/README.html)
- [Async Storage](https://github.com/react-native-async-storage/async-storage)
# Bluesky
## Build instructions
- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
- Setup your environment [for e2e testing using detox](https://wix.github.io/Detox/docs/introduction/getting-started):
- yarn global add detox-cli
- brew tap wix/brew
- brew install applesimutils
- After initial setup:
- `cd ios ; pod install`
- Start the dev servers
@ -34,6 +28,17 @@ Uses:
## Various notes
### Debugging
- Note that since 0.70, debugging using the old debugger (which shows up using CMD+D) doesn't work anymore. Follow the instructions below to debug the code: https://reactnative.dev/docs/next/hermes#debugging-js-on-hermes-using-google-chromes-devtools
### Running E2E Tests
- Make sure you've setup your environment following above
- Make sure Metro and the dev server are running
- Run `yarn e2e`
- Find the artifacts in the `artifact` folder
### Polyfills
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes:

67
__mocks__/react-native-svg.js vendored 100644
View File

@ -0,0 +1,67 @@
// @flow
// https://github.com/FormidableLabs/react-native-svg-mock
import React from 'react'
const createComponent = function (name) {
return class extends React.Component {
// overwrite the displayName, since this is a class created dynamically
static displayName = name
render() {
return React.createElement(name, this.props, this.props.children)
}
}
}
// Mock all react-native-svg exports
// from https://github.com/magicismight/react-native-svg/blob/master/index.js
const Svg = createComponent('Svg')
const Circle = createComponent('Circle')
const Ellipse = createComponent('Ellipse')
const G = createComponent('G')
const Text = createComponent('Text')
const TextPath = createComponent('TextPath')
const TSpan = createComponent('TSpan')
const Path = createComponent('Path')
const Polygon = createComponent('Polygon')
const Polyline = createComponent('Polyline')
const Line = createComponent('Line')
const Rect = createComponent('Rect')
const Use = createComponent('Use')
const Image = createComponent('Image')
const Symbol = createComponent('Symbol')
const Defs = createComponent('Defs')
const LinearGradient = createComponent('LinearGradient')
const RadialGradient = createComponent('RadialGradient')
const Stop = createComponent('Stop')
const ClipPath = createComponent('ClipPath')
const Pattern = createComponent('Pattern')
const Mask = createComponent('Mask')
export {
Svg,
Circle,
Ellipse,
G,
Text,
TextPath,
TSpan,
Path,
Polygon,
Polyline,
Line,
Rect,
Use,
Image,
Symbol,
Defs,
LinearGradient,
RadialGradient,
Stop,
ClipPath,
Pattern,
Mask,
}
export default Svg

View File

@ -1,856 +0,0 @@
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 {FeedModel} from '../src/state/models/feed-view'
import {NotificationsViewModel} from '../src/state/models/notifications-view'
import {ProfileViewModel} from '../src/state/models/profile-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().mockResolvedValue({}),
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 mockedFeedItemStore = {
_reactKey: 'item-1',
_isThreadParent: false,
_isThreadChildElided: false,
_isThreadChild: false,
_hideParent: false,
_isRenderingAsThread: 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'),
registerListeners: jest.fn().mockReturnValue(jest.fn()),
// 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(),
toNotifeeOpts: 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().mockResolvedValue({}),
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().mockResolvedValue({}),
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,
mainFeed: mockedFeedStore,
notifications: mockedNotificationsStore,
clear: jest.fn(),
load: jest.fn(),
clearNotificationCount: jest.fn(),
fetchNotifications: jest.fn(),
bgFetchNotifications: 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: {
actor: {
searchTypeahead: jest.fn().mockResolvedValue({data: {users: []}}),
},
graph: {
getFollows: jest.fn().mockResolvedValue({data: {follows: []}}),
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(),
onPostDeleted: jest.fn(),
emitPostDeleted: jest.fn(),
initBgFetch: jest.fn(),
onBgFetch: jest.fn(),
onBgFetchTimeout: 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,
selectedViewIndex: 0,
rootStore: mockedRootStore,
params: {
user: 'test user',
},
currentView: mockedFeedStore,
isInitialLoading: false,
isRefreshing: false,
isUser: true,
isScene: false,
selectorItems: [Sections.Posts, Sections.PostsWithReplies],
selectedView: Sections.Posts,
setSelectedViewIndex: jest.fn(),
setup: jest.fn().mockResolvedValue({aborted: false}),
update: jest.fn(),
refresh: jest.fn().mockResolvedValue({}),
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().mockResolvedValue({}),
// 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().mockResolvedValue({}),
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().mockResolvedValue({}),
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

View File

@ -1,241 +0,0 @@
import React from 'react'
import {MobileShell} from '../src/view/shell/mobile'
import {cleanup, fireEvent, render, waitFor} from '../jest/test-utils'
import {createServer, TestPDS} from '../jest/test-pds'
import {RootStoreModel, setupState} from '../src/state'
const WAIT_OPTS = {timeout: 5e3}
describe('Account flows', () => {
let pds: TestPDS | undefined
let rootStore: RootStoreModel | undefined
beforeAll(async () => {
jest.useFakeTimers()
pds = await createServer()
rootStore = await setupState(pds.pdsUrl)
})
afterAll(async () => {
jest.clearAllMocks()
cleanup()
await pds?.close()
})
it('renders initial screen', () => {
const {getByTestId} = render(<MobileShell />, rootStore)
const signUpScreen = getByTestId('signinOrCreateAccount')
expect(signUpScreen).toBeTruthy()
})
it('completes signin to the server', async () => {
const {getByTestId} = render(<MobileShell />, rootStore)
// move to signin view
fireEvent.press(getByTestId('signInButton'))
expect(getByTestId('signIn')).toBeTruthy()
expect(getByTestId('loginForm')).toBeTruthy()
// input the target server
expect(getByTestId('loginSelectServiceButton')).toBeTruthy()
fireEvent.press(getByTestId('loginSelectServiceButton'))
expect(getByTestId('serverInputModal')).toBeTruthy()
fireEvent.changeText(
getByTestId('customServerTextInput'),
pds?.pdsUrl || '',
)
fireEvent.press(getByTestId('customServerSelectBtn'))
await waitFor(() => {
expect(getByTestId('loginUsernameInput')).toBeTruthy()
}, WAIT_OPTS)
// enter username & pass
fireEvent.changeText(getByTestId('loginUsernameInput'), 'alice')
fireEvent.changeText(getByTestId('loginPasswordInput'), 'hunter2')
await waitFor(() => {
expect(getByTestId('loginNextButton')).toBeTruthy()
}, WAIT_OPTS)
fireEvent.press(getByTestId('loginNextButton'))
// signed in
await waitFor(() => {
expect(getByTestId('homeFeed')).toBeTruthy()
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(1)
}, WAIT_OPTS)
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(1)
})
it('opens the login screen when "add account" is pressed', async () => {
const {getByTestId, getAllByTestId} = render(<MobileShell />, rootStore)
await waitFor(() => expect(getByTestId('homeFeed')).toBeTruthy(), WAIT_OPTS)
// open side menu
fireEvent.press(getAllByTestId('viewHeaderBackOrMenuBtn')[0])
await waitFor(() => expect(getByTestId('menuView')).toBeTruthy(), WAIT_OPTS)
// nav to settings
fireEvent.press(getByTestId('menuItemButton-Settings'))
await waitFor(
() => expect(getByTestId('settingsScreen')).toBeTruthy(),
WAIT_OPTS,
)
// press '+ new account' in switcher
fireEvent.press(getByTestId('switchToNewAccountBtn'))
await waitFor(
() => expect(getByTestId('signinOrCreateAccount')).toBeTruthy(),
WAIT_OPTS,
)
})
it('shows the "choose account" form when a previous session has been created', async () => {
const {getByTestId} = render(<MobileShell />, rootStore)
// move to signin view
fireEvent.press(getByTestId('signInButton'))
expect(getByTestId('signIn')).toBeTruthy()
expect(getByTestId('chooseAccountForm')).toBeTruthy()
})
it('logs directly into the account due to still possessing session tokens', async () => {
const {getByTestId} = render(<MobileShell />, rootStore)
// move to signin view
fireEvent.press(getByTestId('signInButton'))
expect(getByTestId('signIn')).toBeTruthy()
expect(getByTestId('chooseAccountForm')).toBeTruthy()
// select the previous account
fireEvent.press(getByTestId('chooseAccountBtn-alice.test'))
// signs in immediately
await waitFor(() => {
expect(getByTestId('homeFeed')).toBeTruthy()
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(1)
}, WAIT_OPTS)
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(1)
})
it('logs into a second account via the switcher', async () => {
const {getByTestId, getAllByTestId} = render(<MobileShell />, rootStore)
await waitFor(() => expect(getByTestId('homeFeed')).toBeTruthy(), WAIT_OPTS)
// open side menu
fireEvent.press(getAllByTestId('viewHeaderBackOrMenuBtn')[0])
await waitFor(() => expect(getByTestId('menuView')).toBeTruthy(), WAIT_OPTS)
// nav to settings
fireEvent.press(getByTestId('menuItemButton-Settings'))
await waitFor(
() => expect(getByTestId('settingsScreen')).toBeTruthy(),
WAIT_OPTS,
)
// press '+ new account' in switcher
fireEvent.press(getByTestId('switchToNewAccountBtn'))
await waitFor(
() => expect(getByTestId('signinOrCreateAccount')).toBeTruthy(),
WAIT_OPTS,
)
// move to signin view
fireEvent.press(getByTestId('signInButton'))
expect(getByTestId('signIn')).toBeTruthy()
expect(getByTestId('chooseAccountForm')).toBeTruthy()
// select a new account
fireEvent.press(getByTestId('chooseNewAccountBtn'))
expect(getByTestId('loginForm')).toBeTruthy()
// input the target server
expect(getByTestId('loginSelectServiceButton')).toBeTruthy()
fireEvent.press(getByTestId('loginSelectServiceButton'))
expect(getByTestId('serverInputModal')).toBeTruthy()
fireEvent.changeText(
getByTestId('customServerTextInput'),
pds?.pdsUrl || '',
)
fireEvent.press(getByTestId('customServerSelectBtn'))
await waitFor(
() => expect(getByTestId('loginUsernameInput')).toBeTruthy(),
WAIT_OPTS,
)
// enter username & pass
fireEvent.changeText(getByTestId('loginUsernameInput'), 'bob')
fireEvent.changeText(getByTestId('loginPasswordInput'), 'hunter2')
await waitFor(
() => expect(getByTestId('loginNextButton')).toBeTruthy(),
WAIT_OPTS,
)
fireEvent.press(getByTestId('loginNextButton'))
// signed in
await waitFor(() => {
expect(getByTestId('settingsScreen')).toBeTruthy() // we go back to settings in this situation
expect(rootStore?.me?.displayName).toBe('Bob')
expect(rootStore?.me?.handle).toBe('bob.test')
expect(rootStore?.session.accounts.length).toBe(2)
}, WAIT_OPTS)
expect(rootStore?.me?.displayName).toBe('Bob')
expect(rootStore?.me?.handle).toBe('bob.test')
expect(rootStore?.session.accounts.length).toBe(2)
})
it('can instantly switch between accounts', async () => {
const {getByTestId} = render(<MobileShell />, rootStore)
await waitFor(
() => expect(getByTestId('settingsScreen')).toBeTruthy(),
WAIT_OPTS,
)
// select the alice account
fireEvent.press(getByTestId('switchToAccountBtn-alice.test'))
// swapped account
await waitFor(() => {
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(2)
}, WAIT_OPTS)
expect(rootStore?.me?.displayName).toBe('Alice')
expect(rootStore?.me?.handle).toBe('alice.test')
expect(rootStore?.session.accounts.length).toBe(2)
})
it('will prompt for a password if you sign out', async () => {
const {getByTestId} = render(<MobileShell />, rootStore)
await waitFor(
() => expect(getByTestId('settingsScreen')).toBeTruthy(),
WAIT_OPTS,
)
// press the sign out button
fireEvent.press(getByTestId('signOutBtn'))
// in the logged out state
await waitFor(
() => expect(getByTestId('signinOrCreateAccount')).toBeTruthy(),
WAIT_OPTS,
)
// move to signin view
fireEvent.press(getByTestId('signInButton'))
expect(getByTestId('signIn')).toBeTruthy()
expect(getByTestId('chooseAccountForm')).toBeTruthy()
// select an existing account
fireEvent.press(getByTestId('chooseAccountBtn-alice.test'))
// goes to login screen instead of straight back to settings
expect(getByTestId('loginForm')).toBeTruthy()
})
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,47 @@
import {bundleAsync} from '../../../src/lib/async/bundle'
describe('bundle', () => {
it('bundles multiple simultaneous calls into one execution', async () => {
let calls = 0
const fn = bundleAsync(async () => {
calls++
await new Promise(r => setTimeout(r, 1))
return 'hello'
})
const [res1, res2, res3] = await Promise.all([fn(), fn(), fn()])
expect(calls).toEqual(1)
expect(res1).toEqual('hello')
expect(res2).toEqual('hello')
expect(res3).toEqual('hello')
})
it('does not bundle non-simultaneous calls', async () => {
let calls = 0
const fn = bundleAsync(async () => {
calls++
await new Promise(r => setTimeout(r, 1))
return 'hello'
})
const res1 = await fn()
const res2 = await fn()
const res3 = await fn()
expect(calls).toEqual(3)
expect(res1).toEqual('hello')
expect(res2).toEqual('hello')
expect(res3).toEqual('hello')
})
it('is not affected by rejections', async () => {
let calls = 0
const fn = bundleAsync(async () => {
calls++
await new Promise(r => setTimeout(r, 1))
throw new Error()
})
const res1 = await fn().catch(() => 'reject')
const res2 = await fn().catch(() => 'reject')
const res3 = await fn().catch(() => 'reject')
expect(calls).toEqual(3)
expect(res1).toEqual('reject')
expect(res2).toEqual('reject')
expect(res3).toEqual('reject')
})
})

View File

@ -1,4 +1,4 @@
import {isNetworkError} from '../../src/lib/errors'
import {isNetworkError} from '../../src/lib/strings/errors'
describe('isNetworkError', () => {
const inputs = [

View File

@ -1,7 +1,8 @@
import {extractHtmlMeta} from '../../src/lib/extractHtmlMeta'
import {extractHtmlMeta} from '../../src/lib/link-meta/html'
import {exampleComHtml} from './__mocks__/exampleComHtml'
import {youtubeHTML} from './__mocks__/youtubeHtml'
import {tiktokHtml} from './__mocks__/tiktokHtml'
import {youtubeChannelHtml} from './__mocks__/youtubeChannelHtml'
describe('extractHtmlMeta', () => {
const cases = [
@ -82,6 +83,19 @@ describe('extractHtmlMeta', () => {
expect(output).toEqual(expectedOutput)
})
it('extracts avatar from a youtube channel', () => {
const input = youtubeChannelHtml
const expectedOutput = {
title: 'penguinz0',
description:
'Clips channel: https://www.youtube.com/channel/UC4EQHfzIbkL_Skit_iKt1aA\n\nTwitter: https://twitter.com/MoistCr1TiKaL\n\nInstagram: https://www.instagram.com/bigmoistcr1tikal/?hl=en\n\nTwitch: https://www.twitch.tv/moistcr1tikal\n\nSnapchat: Hugecharles\n\nTik Tok: Hugecharles\n\nI don&#39;t have any other public accounts.',
image:
'https://yt3.googleusercontent.com/ytc/AL5GRJWOhJOuUC6C2b7gP-5D2q6ypXbcOOckyAE1En4RUQ=s176-c-k-c0x00ffffff-no-rj',
}
const output = extractHtmlMeta({html: input, hostname: 'youtube.com'})
expect(output).toEqual(expectedOutput)
})
it('extracts username from the url a twitter profile page', () => {
const expectedOutput = {
title: '@bluesky on Twitter',

View File

@ -78,8 +78,14 @@ describe('downloadAndResize', () => {
})
it('should return undefined for unsupported file type', async () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image'),
flush: jest.fn(),
})
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.bmp',
uri: 'https://example.com/image',
width: 100,
height: 100,
maxSize: 500000,
@ -88,6 +94,25 @@ describe('downloadAndResize', () => {
}
const result = await downloadAndResize(opts)
expect(result).toBeUndefined()
expect(result).toEqual(mockResizedImage)
expect(RNFetchBlob.config).toHaveBeenCalledWith({
fileCache: true,
appendExt: 'jpeg',
})
expect(RNFetchBlob.fetch).toHaveBeenCalledWith(
'GET',
'https://example.com/image',
)
expect(ImageResizer.createResizedImage).toHaveBeenCalledWith(
'file://downloaded-image',
100,
100,
'JPEG',
100,
undefined,
undefined,
undefined,
{mode: 'cover'},
)
})
})

View File

@ -1,8 +1,19 @@
import {LikelyType, getLinkMeta, getLikelyType} from '../../src/lib/link-meta'
import {
LikelyType,
getLinkMeta,
getLikelyType,
} from '../../src/lib/link-meta/link-meta'
import {exampleComHtml} from './__mocks__/exampleComHtml'
import {mockedRootStore} from '../../__mocks__/state-mock'
import AtpAgent from '@atproto/api'
import {DEFAULT_SERVICE, RootStoreModel} from '../../src/state'
describe('getLinkMeta', () => {
let rootStore: RootStoreModel
beforeEach(() => {
rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE}))
})
const inputs = [
'',
'httpbadurl',
@ -88,7 +99,7 @@ describe('getLinkMeta', () => {
})
})
const input = inputs[i]
const output = await getLinkMeta(mockedRootStore, input)
const output = await getLinkMeta(rootStore, input)
expect(output).toEqual(outputs[i])
}
})

View File

@ -1,17 +1,18 @@
import {
extractEntities,
detectLinkables,
pluralize,
getYoutubeVideoId,
makeRecordUri,
ago,
makeValidHandle,
createFullHandle,
enforceLen,
cleanError,
toNiceDomain,
toShortUrl,
toShareUrl,
} from '../../src/lib/strings'
} from '../../src/lib/strings/url-helpers'
import {pluralize, enforceLen} from '../../src/lib/strings/helpers'
import {ago} from '../../src/lib/strings/time'
import {
extractEntities,
detectLinkables,
} from '../../src/lib/strings/rich-text-detection'
import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles'
import {cleanError} from '../../src/lib/strings/errors'
describe('extractEntities', () => {
const knownHandles = new Set(['handle.com', 'full123.test-of-chars'])
@ -487,3 +488,29 @@ describe('toShareUrl', () => {
}
})
})
describe('getYoutubeVideoId', () => {
it(' should return undefined for invalid youtube links', () => {
expect(getYoutubeVideoId('')).toBeUndefined()
expect(getYoutubeVideoId('https://www.google.com')).toBeUndefined()
expect(getYoutubeVideoId('https://www.youtube.com')).toBeUndefined()
expect(
getYoutubeVideoId('https://www.youtube.com/channelName'),
).toBeUndefined()
expect(
getYoutubeVideoId('https://www.youtube.com/channel/channelName'),
).toBeUndefined()
})
it('getYoutubeVideoId should return video id for valid youtube links', () => {
expect(getYoutubeVideoId('https://www.youtube.com/watch?v=videoId')).toBe(
'videoId',
)
expect(
getYoutubeVideoId(
'https://www.youtube.com/watch?v=videoId&feature=share',
),
).toBe('videoId')
expect(getYoutubeVideoId('https://youtu.be/videoId')).toBe('videoId')
})
})

View File

@ -0,0 +1,84 @@
import {
getMentionAt,
insertMentionAt,
} from '../../../src/lib/strings/mention-manip'
describe('getMentionAt', () => {
type Case = [string, number, string | undefined]
const cases: Case[] = [
['hello @alice goodbye', 0, undefined],
['hello @alice goodbye', 1, undefined],
['hello @alice goodbye', 2, undefined],
['hello @alice goodbye', 3, undefined],
['hello @alice goodbye', 4, undefined],
['hello @alice goodbye', 5, undefined],
['hello @alice goodbye', 6, 'alice'],
['hello @alice goodbye', 7, 'alice'],
['hello @alice goodbye', 8, 'alice'],
['hello @alice goodbye', 9, 'alice'],
['hello @alice goodbye', 10, 'alice'],
['hello @alice goodbye', 11, 'alice'],
['hello @alice goodbye', 12, 'alice'],
['hello @alice goodbye', 13, undefined],
['hello @alice goodbye', 14, undefined],
['@alice', 0, 'alice'],
['@alice hello', 0, 'alice'],
['@alice hello', 1, 'alice'],
['@alice hello', 2, 'alice'],
['@alice hello', 3, 'alice'],
['@alice hello', 4, 'alice'],
['@alice hello', 5, 'alice'],
['@alice hello', 6, 'alice'],
['@alice hello', 7, undefined],
['alice@alice', 0, undefined],
['alice@alice', 6, undefined],
]
it.each(cases)(
'given input string %p and cursor position %p, returns %p',
(str, cursorPos, expected) => {
const output = getMentionAt(str, cursorPos)
expect(output?.value).toEqual(expected)
},
)
})
describe('insertMentionAt', () => {
type Case = [string, number, string]
const cases: Case[] = [
['hello @alice goodbye', 0, 'hello @alice goodbye'],
['hello @alice goodbye', 1, 'hello @alice goodbye'],
['hello @alice goodbye', 2, 'hello @alice goodbye'],
['hello @alice goodbye', 3, 'hello @alice goodbye'],
['hello @alice goodbye', 4, 'hello @alice goodbye'],
['hello @alice goodbye', 5, 'hello @alice goodbye'],
['hello @alice goodbye', 6, 'hello @alice.com goodbye'],
['hello @alice goodbye', 7, 'hello @alice.com goodbye'],
['hello @alice goodbye', 8, 'hello @alice.com goodbye'],
['hello @alice goodbye', 9, 'hello @alice.com goodbye'],
['hello @alice goodbye', 10, 'hello @alice.com goodbye'],
['hello @alice goodbye', 11, 'hello @alice.com goodbye'],
['hello @alice goodbye', 12, 'hello @alice.com goodbye'],
['hello @alice goodbye', 13, 'hello @alice goodbye'],
['hello @alice goodbye', 14, 'hello @alice goodbye'],
['@alice', 0, '@alice.com '],
['@alice hello', 0, '@alice.com hello'],
['@alice hello', 1, '@alice.com hello'],
['@alice hello', 2, '@alice.com hello'],
['@alice hello', 3, '@alice.com hello'],
['@alice hello', 4, '@alice.com hello'],
['@alice hello', 5, '@alice.com hello'],
['@alice hello', 6, '@alice.com hello'],
['@alice hello', 7, '@alice hello'],
['alice@alice', 0, 'alice@alice'],
['alice@alice', 6, 'alice@alice'],
]
it.each(cases)(
'given input string %p and cursor position %p, returns %p',
(str, cursorPos, expected) => {
const output = insertMentionAt(str, cursorPos, 'alice.com')
expect(output).toEqual(expected)
},
)
})

View File

@ -0,0 +1,123 @@
import {AppBskyFeedPost} from '@atproto/api'
type Entity = AppBskyFeedPost.Entity
import {RichText} from '../../../src/lib/strings/rich-text'
import {removeExcessNewlines} from '../../../src/lib/strings/rich-text-sanitize'
describe('removeExcessNewlines', () => {
it('removes more than two consecutive new lines', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with spaces', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n \n \n \n \n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n \n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('returns original string if there are no consecutive new lines', () => {
const input = new RichText('test\n\ntest\n\ntest\n\ntest\n\ntest')
const output = removeExcessNewlines(input)
expect(output.text).toEqual(input.text)
})
it('returns original string if there are no new lines', () => {
const input = new RichText('test test test test test')
const output = removeExcessNewlines(input)
expect(output.text).toEqual(input.text)
})
it('returns empty string if input is empty', () => {
const input = new RichText('')
const output = removeExcessNewlines(input)
expect(output.text).toEqual('')
})
it('works with different types of new line characters', () => {
const input = new RichText(
'test\r\ntest\n\rtest\rtest\n\n\n\ntest\n\r \n \n \n \n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\r\ntest\n\rtest\rtest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with zero width space', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\u200B\u200B\n\n\n\ntest\n \u200B\u200B \n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with zero width non-joiner', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\u200C\u200C\n\n\n\ntest\n \u200C\u200C \n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with zero width joiner', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\u200D\u200D\n\n\n\ntest\n \u200D\u200D \n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with soft hyphen', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\u00AD\u00AD\n\n\n\ntest\n \u00AD\u00AD \n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with word joiner', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\u2060\u2060\n\n\n\ntest\n \u2060\u2060 \n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
})
describe('removeExcessNewlines w/entities', () => {
it('preserves entities as expected', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest',
[
{index: {start: 0, end: 13}, type: '', value: ''},
{index: {start: 13, end: 24}, type: '', value: ''},
{index: {start: 9, end: 15}, type: '', value: ''},
{index: {start: 4, end: 9}, type: '', value: ''},
],
)
const output = removeExcessNewlines(input)
expect(entToStr(input.text, input.entities?.[0])).toEqual(
'test\n\n\n\n\ntest',
)
expect(entToStr(input.text, input.entities?.[1])).toEqual(
'\n\n\n\n\n\n\ntest',
)
expect(entToStr(input.text, input.entities?.[2])).toEqual('test\n\n')
expect(entToStr(input.text, input.entities?.[3])).toEqual('\n\n\n\n\n')
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
expect(entToStr(output.text, output.entities?.[0])).toEqual('test\n\ntest')
expect(entToStr(output.text, output.entities?.[1])).toEqual('test')
expect(entToStr(output.text, output.entities?.[2])).toEqual('test')
expect(output.entities?.[3]).toEqual(undefined)
})
})
function entToStr(str: string, ent?: Entity) {
if (!ent) {
return ''
}
return str.slice(ent.index.start, ent.index.end)
}

View File

@ -0,0 +1,123 @@
import {RichText} from '../../../src/lib/strings/rich-text'
describe('richText.insert', () => {
const input = new RichText('hello world', [
{index: {start: 2, end: 7}, type: '', value: ''},
])
it('correctly adjusts entities (scenario A - before)', () => {
const output = input.clone().insert(0, 'test')
expect(output.text).toEqual('testhello world')
expect(output.entities?.[0].index.start).toEqual(6)
expect(output.entities?.[0].index.end).toEqual(11)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('llo w')
})
it('correctly adjusts entities (scenario B - inner)', () => {
const output = input.clone().insert(4, 'test')
expect(output.text).toEqual('helltesto world')
expect(output.entities?.[0].index.start).toEqual(2)
expect(output.entities?.[0].index.end).toEqual(11)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('lltesto w')
})
it('correctly adjusts entities (scenario C - after)', () => {
const output = input.clone().insert(8, 'test')
expect(output.text).toEqual('hello wotestrld')
expect(output.entities?.[0].index.start).toEqual(2)
expect(output.entities?.[0].index.end).toEqual(7)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('llo w')
})
})
describe('richText.delete', () => {
const input = new RichText('hello world', [
{index: {start: 2, end: 7}, type: '', value: ''},
])
it('correctly adjusts entities (scenario A - entirely outer)', () => {
const output = input.clone().delete(0, 9)
expect(output.text).toEqual('ld')
expect(output.entities?.length).toEqual(0)
})
it('correctly adjusts entities (scenario B - entirely after)', () => {
const output = input.clone().delete(7, 11)
expect(output.text).toEqual('hello w')
expect(output.entities?.[0].index.start).toEqual(2)
expect(output.entities?.[0].index.end).toEqual(7)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('llo w')
})
it('correctly adjusts entities (scenario C - partially after)', () => {
const output = input.clone().delete(4, 11)
expect(output.text).toEqual('hell')
expect(output.entities?.[0].index.start).toEqual(2)
expect(output.entities?.[0].index.end).toEqual(4)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('ll')
})
it('correctly adjusts entities (scenario D - entirely inner)', () => {
const output = input.clone().delete(3, 5)
expect(output.text).toEqual('hel world')
expect(output.entities?.[0].index.start).toEqual(2)
expect(output.entities?.[0].index.end).toEqual(5)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('l w')
})
it('correctly adjusts entities (scenario E - partially before)', () => {
const output = input.clone().delete(1, 5)
expect(output.text).toEqual('h world')
expect(output.entities?.[0].index.start).toEqual(1)
expect(output.entities?.[0].index.end).toEqual(3)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual(' w')
})
it('correctly adjusts entities (scenario F - entirely before)', () => {
const output = input.clone().delete(0, 2)
expect(output.text).toEqual('llo world')
expect(output.entities?.[0].index.start).toEqual(0)
expect(output.entities?.[0].index.end).toEqual(5)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('llo w')
})
})

View File

@ -1,72 +0,0 @@
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(rootStore, 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()
}
})
})
})

View File

@ -1,153 +0,0 @@
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}',
)
})
})

View File

@ -1,180 +0,0 @@
import {RootStoreModel} from '../../../src/state/models/root-store'
import {MeModel} from '../../../src/state/models/me'
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)
})
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)
})
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().mockResolvedValue({}),
} as unknown as NotificationsViewModel
jest
.spyOn(rootStore.api.app.bsky.notification, 'getCount')
.mockImplementationOnce((): Promise<any> => {
return Promise.resolve({
data: {
count: 1,
},
})
})
await meModel.fetchNotifications()
expect(meModel.notificationCount).toBe(1)
expect(meModel.notifications.refresh).toHaveBeenCalled()
})
})

View File

@ -1,11 +1,16 @@
import {RootStoreModel} from './../../../src/state/models/root-store'
import {NavigationModel} from './../../../src/state/models/navigation'
import * as flags from '../../../src/build-flags'
import * as flags from '../../../src/lib/build-flags'
import AtpAgent from '@atproto/api'
import {DEFAULT_SERVICE} from '../../../src/state'
describe('NavigationModel', () => {
let model: NavigationModel
let rootStore: RootStoreModel
beforeEach(() => {
model = new NavigationModel()
rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE}))
model = new NavigationModel(rootStore)
model.setTitle('0-0', 'title')
})
@ -15,7 +20,7 @@ describe('NavigationModel', () => {
it('should clear() to the correct base state', async () => {
await model.clear()
expect(model.tabCount).toBe(2)
expect(model.tabCount).toBe(3)
expect(model.tab).toEqual({
fixedTabPurpose: 0,
history: [
@ -64,7 +69,7 @@ describe('NavigationModel', () => {
})
it('should call the tabCount getter', () => {
expect(model.tabCount).toBe(2)
expect(model.tabCount).toBe(3)
})
describe('tabs not enabled', () => {
@ -87,7 +92,7 @@ describe('NavigationModel', () => {
it('should not change the active tab', () => {
// @ts-expect-error
flags.TABS_ENABLED = false
model.setActiveTab(2)
model.setActiveTab(3)
expect(model.tabIndex).toBe(0)
})
@ -95,57 +100,58 @@ describe('NavigationModel', () => {
// @ts-expect-error
flags.TABS_ENABLED = false
model.closeTab(0)
expect(model.tabCount).toBe(2)
expect(model.tabCount).toBe(3)
})
})
describe('tabs enabled', () => {
jest.mock('../../../src/build-flags', () => ({
TABS_ENABLED: true,
}))
// TODO restore when tabs get re-enabled
// 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)
})
})
// 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)
// })
// })
})

View File

@ -1,59 +0,0 @@
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('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)
})
})

View File

@ -1,61 +0,0 @@
import {
ConfirmModal,
ImagesLightbox,
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', () => {
const m = new ConfirmModal('Test Modal', 'Look good?', () => {})
model.openModal(m)
expect(model.isModalActive).toEqual(true)
expect(model.activeModal).toEqual(m)
model.closeModal()
expect(model.isModalActive).toEqual(false)
expect(model.activeModal).toBeUndefined()
})
it('should call the openLightbox & closeLightbox method', () => {
const lt = new ImagesLightbox(['uri'], 0)
model.openLightbox(lt)
expect(model.isLightboxActive).toEqual(true)
expect(model.activeLightbox).toEqual(lt)
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()
})
})

View File

@ -1,43 +0,0 @@
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')
})
})

View File

@ -1,118 +0,0 @@
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',
undefined,
[],
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()
})
})

View File

@ -1,70 +0,0 @@
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'])
})
})

View File

@ -1,58 +0,0 @@
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 {
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()
})
})

View File

@ -1,110 +0,0 @@
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',
)
})
// TODO - this will only pass if the profile has an avatar image set
// 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()
})
})

View File

@ -1,17 +0,0 @@
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)
})
})

View File

@ -1,49 +0,0 @@
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',
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,
)
})
})

View File

@ -1,37 +0,0 @@
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()
})
})

View File

@ -1,21 +0,0 @@
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('/')
})
})

View File

@ -1,30 +0,0 @@
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',
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()
})
})

View File

@ -1,57 +0,0 @@
import React from 'react'
import {Menu} from '../../../../src/view/shell/mobile/Menu'
import {cleanup, fireEvent, render} from '../../../../jest/test-utils'
import {mockedNavigationStore} 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 {getByTestId} = render(<Menu {...mockedProps} />)
const menuItemButton = getByTestId('menuItemButton-Notifications')
fireEvent.press(menuItemButton)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(1, true)
})
})

View File

@ -1,100 +0,0 @@
import React from 'react'
import {Animated} 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()
})
// TODO - this throws currently, but the tabs selector isnt being used atm so I just disabled -prf
// 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)
})
})

View File

@ -14,9 +14,9 @@ react {
// The root of your project, i.e. where "package.json" lives. Default is '..'
// root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../node-modules/react-native")
// reactNativeDir = file("../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../node_modules/react-native-codegen
// codegenDir = file("../node-modules/react-native-codegen")
// codegenDir = file("../node_modules/react-native-codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
// cliFile = file("../node_modules/react-native/cli.js")
/* Variants */

View File

@ -14,6 +14,17 @@ module.exports = {
verbose: false,
},
],
[
'module-resolver',
{
alias: {
// This needs to be mirrored in tsconfig.json
lib: './src/lib',
state: './src/state',
view: './src/view',
},
},
],
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
],
}

12
e2e/jest.config.js 100644
View File

@ -0,0 +1,12 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.test.js'],
testTimeout: 120000,
maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
verbose: true,
}

View File

@ -0,0 +1,77 @@
/* eslint-env detox/detox */
describe('Example', () => {
async function grantAccessToUserWithValidCredentials(
username,
{takeScreenshots} = {takeScreenshots: false},
) {
await element(by.id('signInButton')).tap()
if (takeScreenshots) {
await device.takeScreenshot('1- opened sign-in screen')
}
await element(by.id('loginSelectServiceButton')).tap()
if (takeScreenshots) {
await device.takeScreenshot('2- opened service selector')
}
await element(by.id('localDevServerButton')).tap()
if (takeScreenshots) {
await device.takeScreenshot('3- selected local dev server')
}
await element(by.id('loginUsernameInput')).typeText(username)
await element(by.id('loginPasswordInput')).typeText('hunter2')
if (takeScreenshots) {
await device.takeScreenshot('4- entered username and password')
}
await element(by.id('loginNextButton')).tap()
}
beforeEach(async () => {
await device.uninstallApp()
await device.installApp()
await device.launchApp({permissions: {notifications: 'YES'}})
})
it('As Alice, I can login', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await grantAccessToUserWithValidCredentials('alice', {
takeScreenshots: true,
})
await device.takeScreenshot('5- opened home screen')
})
it('As Alice, I can login, and post a text', async () => {
await grantAccessToUserWithValidCredentials('alice')
await element(by.id('composeFAB')).tap()
await device.takeScreenshot('1- opened composer')
await element(by.id('composerTextInput')).typeText(
'Greetings earthlings, I come in peace... and to run some tests.',
)
await device.takeScreenshot('2- entered text')
await element(by.id('composerPublishButton')).tap()
await device.takeScreenshot('3- opened general section')
await expect(element(by.id('composeFAB'))).toBeVisible()
})
it('I can create a new account', async () => {
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('registerSelectServiceButton')).tap()
await device.takeScreenshot('2- opened service selector')
await element(by.id('localDevServerButton')).tap()
await device.takeScreenshot('3- selected local dev server')
await element(by.id('registerEmailInput')).typeText('example@test.com')
await element(by.id('registerPasswordInput')).typeText('hunter2')
await element(by.id('registerHandleInput')).typeText('e2e-test')
await element(by.id('registerIs13Input')).tap()
await device.takeScreenshot('4- entered account details')
await element(by.id('createAccountButton')).tap()
await expect(element(by.id('onboardFeatureExplainerSkipBtn'))).toBeVisible()
await expect(element(by.id('onboardFeatureExplainerNextBtn'))).toBeVisible()
await device.takeScreenshot('5- onboard feature explainer')
await element(by.id('onboardFeatureExplainerSkipBtn')).tap()
await expect(element(by.id('onboardFollowsSkipBtn'))).toBeVisible()
await expect(element(by.id('onboardFollowsNextBtn'))).toBeVisible()
await device.takeScreenshot('6- onboard follows recommender')
await element(by.id('onboardFollowsSkipBtn')).tap()
})
})

View File

@ -4,6 +4,15 @@ require_relative '../node_modules/@react-native-community/cli-platform-ios/nativ
platform :ios, min_ios_version_supported
prepare_react_native_project!
# If you are using a `react-native-flipper` your iOS build will fail when `NO_FLIPPER=1` is set.
# because `react-native-flipper` depends on (FlipperKit,...) that will be excluded
#
# To fix this you can also exclude `react-native-flipper` using a `react-native.config.js`
# ```js
# module.exports = {
# dependencies: {
# ...(process.env.NO_FLIPPER ? { 'react-native-flipper': { platforms: { ios: null } } } : {}),
# ```
flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled
linkage = ENV['USE_FRAMEWORKS']
@ -35,12 +44,28 @@ target 'app' do
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
# react-native-permissions settings
permissions_path = '../node_modules/react-native-permissions/ios'
pod 'Permission-Camera', :path => "#{permissions_path}/Camera"
pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary"
target 'appTests' do
inherit! :complete
# Pods for testing
end
post_install do |installer|
# Temporary fix until CocoaPods 1.12.0 is released.
# https://github.com/CocoaPods/CocoaPods/issues/11402#issuecomment-1201464693
installer.pods_project.targets.each do |target|
if target.respond_to?(:product_type) and target.product_type == "com.apple.product-type.bundle"
target.build_configurations.each do |config|
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
end
end
end
react_native_post_install(
installer,
# Set `mac_catalyst_enabled` to `true` in order to apply patches

View File

@ -3,20 +3,33 @@ PODS:
- BVLinearGradient (2.6.2):
- React-Core
- DoubleConversion (1.1.6)
- FBLazyVector (0.71.0)
- FBReactNativeSpec (0.71.0):
- FBLazyVector (0.71.1)
- FBReactNativeSpec (0.71.1):
- RCT-Folly (= 2021.07.22.00)
- RCTRequired (= 0.71.0)
- RCTTypeSafety (= 0.71.0)
- React-Core (= 0.71.0)
- React-jsi (= 0.71.0)
- ReactCommon/turbomodule/core (= 0.71.0)
- RCTRequired (= 0.71.1)
- RCTTypeSafety (= 0.71.1)
- React-Core (= 0.71.1)
- React-jsi (= 0.71.1)
- ReactCommon/turbomodule/core (= 0.71.1)
- fmt (6.2.1)
- glog (0.3.5)
- hermes-engine (0.71.0):
- hermes-engine/Pre-built (= 0.71.0)
- hermes-engine/Pre-built (0.71.0)
- hermes-engine (0.71.1):
- hermes-engine/Pre-built (= 0.71.1)
- hermes-engine/Pre-built (0.71.1)
- libevent (2.1.12)
- libwebp (1.2.4):
- libwebp/demux (= 1.2.4)
- libwebp/mux (= 1.2.4)
- libwebp/webp (= 1.2.4)
- libwebp/demux (1.2.4):
- libwebp/webp
- libwebp/mux (1.2.4):
- libwebp/demux
- libwebp/webp (1.2.4)
- Permission-Camera (3.6.1):
- RNPermissions
- Permission-PhotoLibrary (3.6.1):
- RNPermissions
- RCT-Folly (2021.07.22.00):
- boost
- DoubleConversion
@ -34,26 +47,26 @@ PODS:
- fmt (~> 6.2.1)
- glog
- libevent
- RCTRequired (0.71.0)
- RCTTypeSafety (0.71.0):
- FBLazyVector (= 0.71.0)
- RCTRequired (= 0.71.0)
- React-Core (= 0.71.0)
- React (0.71.0):
- React-Core (= 0.71.0)
- React-Core/DevSupport (= 0.71.0)
- React-Core/RCTWebSocket (= 0.71.0)
- React-RCTActionSheet (= 0.71.0)
- React-RCTAnimation (= 0.71.0)
- React-RCTBlob (= 0.71.0)
- React-RCTImage (= 0.71.0)
- React-RCTLinking (= 0.71.0)
- React-RCTNetwork (= 0.71.0)
- React-RCTSettings (= 0.71.0)
- React-RCTText (= 0.71.0)
- React-RCTVibration (= 0.71.0)
- React-callinvoker (0.71.0)
- React-Codegen (0.71.0):
- RCTRequired (0.71.1)
- RCTTypeSafety (0.71.1):
- FBLazyVector (= 0.71.1)
- RCTRequired (= 0.71.1)
- React-Core (= 0.71.1)
- React (0.71.1):
- React-Core (= 0.71.1)
- React-Core/DevSupport (= 0.71.1)
- React-Core/RCTWebSocket (= 0.71.1)
- React-RCTActionSheet (= 0.71.1)
- React-RCTAnimation (= 0.71.1)
- React-RCTBlob (= 0.71.1)
- React-RCTImage (= 0.71.1)
- React-RCTLinking (= 0.71.1)
- React-RCTNetwork (= 0.71.1)
- React-RCTSettings (= 0.71.1)
- React-RCTText (= 0.71.1)
- React-RCTVibration (= 0.71.1)
- React-callinvoker (0.71.1)
- React-Codegen (0.71.1):
- FBReactNativeSpec
- hermes-engine
- RCT-Folly
@ -64,190 +77,190 @@ PODS:
- React-jsiexecutor
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- React-Core (0.71.0):
- React-Core (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default (= 0.71.0)
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-Core/Default (= 0.71.1)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/CoreModulesHeaders (0.71.0):
- React-Core/CoreModulesHeaders (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/Default (0.71.0):
- React-Core/Default (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/DevSupport (0.71.0):
- React-Core/DevSupport (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default (= 0.71.0)
- React-Core/RCTWebSocket (= 0.71.0)
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-jsinspector (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-Core/Default (= 0.71.1)
- React-Core/RCTWebSocket (= 0.71.1)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-jsinspector (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/RCTActionSheetHeaders (0.71.0):
- React-Core/RCTActionSheetHeaders (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/RCTAnimationHeaders (0.71.0):
- React-Core/RCTAnimationHeaders (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/RCTBlobHeaders (0.71.0):
- React-Core/RCTBlobHeaders (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/RCTImageHeaders (0.71.0):
- React-Core/RCTImageHeaders (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/RCTLinkingHeaders (0.71.0):
- React-Core/RCTLinkingHeaders (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/RCTNetworkHeaders (0.71.0):
- React-Core/RCTNetworkHeaders (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/RCTSettingsHeaders (0.71.0):
- React-Core/RCTSettingsHeaders (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/RCTTextHeaders (0.71.0):
- React-Core/RCTTextHeaders (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/RCTVibrationHeaders (0.71.0):
- React-Core/RCTVibrationHeaders (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-Core/RCTWebSocket (0.71.0):
- React-Core/RCTWebSocket (0.71.1):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default (= 0.71.0)
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-Core/Default (= 0.71.1)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-perflogger (= 0.71.1)
- Yoga
- React-CoreModules (0.71.0):
- React-CoreModules (0.71.1):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.71.0)
- React-Codegen (= 0.71.0)
- React-Core/CoreModulesHeaders (= 0.71.0)
- React-jsi (= 0.71.0)
- React-RCTImage (= 0.71.0)
- ReactCommon/turbomodule/core (= 0.71.0)
- React-cxxreact (0.71.0):
- RCTTypeSafety (= 0.71.1)
- React-Codegen (= 0.71.1)
- React-Core/CoreModulesHeaders (= 0.71.1)
- React-jsi (= 0.71.1)
- React-RCTImage (= 0.71.1)
- ReactCommon/turbomodule/core (= 0.71.1)
- React-cxxreact (0.71.1):
- boost (= 1.76.0)
- DoubleConversion
- glog
- RCT-Folly (= 2021.07.22.00)
- React-callinvoker (= 0.71.0)
- React-jsi (= 0.71.0)
- React-jsinspector (= 0.71.0)
- React-logger (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-runtimeexecutor (= 0.71.0)
- React-hermes (0.71.0):
- React-callinvoker (= 0.71.1)
- React-jsi (= 0.71.1)
- React-jsinspector (= 0.71.1)
- React-logger (= 0.71.1)
- React-perflogger (= 0.71.1)
- React-runtimeexecutor (= 0.71.1)
- React-hermes (0.71.1):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- RCT-Folly/Futures (= 2021.07.22.00)
- React-cxxreact (= 0.71.0)
- React-jsiexecutor (= 0.71.0)
- React-jsinspector (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-jsi (0.71.0):
- React-cxxreact (= 0.71.1)
- React-jsiexecutor (= 0.71.1)
- React-jsinspector (= 0.71.1)
- React-perflogger (= 0.71.1)
- React-jsi (0.71.1):
- boost (= 1.76.0)
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-jsiexecutor (0.71.0):
- React-jsiexecutor (0.71.1):
- DoubleConversion
- glog
- RCT-Folly (= 2021.07.22.00)
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-jsinspector (0.71.0)
- React-logger (0.71.0):
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-perflogger (= 0.71.1)
- React-jsinspector (0.71.1)
- React-logger (0.71.1):
- glog
- react-native-blur (4.3.0):
- React-Core
- react-native-cameraroll (5.2.2):
- react-native-cameraroll (5.2.4):
- React-Core
- react-native-image-resizer (3.0.4):
- react-native-image-resizer (3.0.5):
- React-Core
- react-native-pager-view (6.1.2):
- react-native-pager-view (6.1.4):
- React-Core
- react-native-paste-input (0.6.0):
- react-native-paste-input (0.6.2):
- React-Core
- Swime (= 3.0.6)
- react-native-safe-area-context (4.4.1):
- react-native-safe-area-context (4.5.0):
- RCT-Folly
- RCTRequired
- RCTTypeSafety
@ -257,87 +270,89 @@ PODS:
- React-Core
- react-native-version-number (0.3.6):
- React
- React-perflogger (0.71.0)
- React-RCTActionSheet (0.71.0):
- React-Core/RCTActionSheetHeaders (= 0.71.0)
- React-RCTAnimation (0.71.0):
- react-native-webview (11.26.1):
- React-Core
- React-perflogger (0.71.1)
- React-RCTActionSheet (0.71.1):
- React-Core/RCTActionSheetHeaders (= 0.71.1)
- React-RCTAnimation (0.71.1):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.71.0)
- React-Codegen (= 0.71.0)
- React-Core/RCTAnimationHeaders (= 0.71.0)
- React-jsi (= 0.71.0)
- ReactCommon/turbomodule/core (= 0.71.0)
- React-RCTAppDelegate (0.71.0):
- RCTTypeSafety (= 0.71.1)
- React-Codegen (= 0.71.1)
- React-Core/RCTAnimationHeaders (= 0.71.1)
- React-jsi (= 0.71.1)
- ReactCommon/turbomodule/core (= 0.71.1)
- React-RCTAppDelegate (0.71.1):
- RCT-Folly
- RCTRequired
- RCTTypeSafety
- React-Core
- ReactCommon/turbomodule/core
- React-RCTBlob (0.71.0):
- React-RCTBlob (0.71.1):
- RCT-Folly (= 2021.07.22.00)
- React-Codegen (= 0.71.0)
- React-Core/RCTBlobHeaders (= 0.71.0)
- React-Core/RCTWebSocket (= 0.71.0)
- React-jsi (= 0.71.0)
- React-RCTNetwork (= 0.71.0)
- ReactCommon/turbomodule/core (= 0.71.0)
- React-RCTImage (0.71.0):
- React-Codegen (= 0.71.1)
- React-Core/RCTBlobHeaders (= 0.71.1)
- React-Core/RCTWebSocket (= 0.71.1)
- React-jsi (= 0.71.1)
- React-RCTNetwork (= 0.71.1)
- ReactCommon/turbomodule/core (= 0.71.1)
- React-RCTImage (0.71.1):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.71.0)
- React-Codegen (= 0.71.0)
- React-Core/RCTImageHeaders (= 0.71.0)
- React-jsi (= 0.71.0)
- React-RCTNetwork (= 0.71.0)
- ReactCommon/turbomodule/core (= 0.71.0)
- React-RCTLinking (0.71.0):
- React-Codegen (= 0.71.0)
- React-Core/RCTLinkingHeaders (= 0.71.0)
- React-jsi (= 0.71.0)
- ReactCommon/turbomodule/core (= 0.71.0)
- React-RCTNetwork (0.71.0):
- RCTTypeSafety (= 0.71.1)
- React-Codegen (= 0.71.1)
- React-Core/RCTImageHeaders (= 0.71.1)
- React-jsi (= 0.71.1)
- React-RCTNetwork (= 0.71.1)
- ReactCommon/turbomodule/core (= 0.71.1)
- React-RCTLinking (0.71.1):
- React-Codegen (= 0.71.1)
- React-Core/RCTLinkingHeaders (= 0.71.1)
- React-jsi (= 0.71.1)
- ReactCommon/turbomodule/core (= 0.71.1)
- React-RCTNetwork (0.71.1):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.71.0)
- React-Codegen (= 0.71.0)
- React-Core/RCTNetworkHeaders (= 0.71.0)
- React-jsi (= 0.71.0)
- ReactCommon/turbomodule/core (= 0.71.0)
- React-RCTSettings (0.71.0):
- RCTTypeSafety (= 0.71.1)
- React-Codegen (= 0.71.1)
- React-Core/RCTNetworkHeaders (= 0.71.1)
- React-jsi (= 0.71.1)
- ReactCommon/turbomodule/core (= 0.71.1)
- React-RCTSettings (0.71.1):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.71.0)
- React-Codegen (= 0.71.0)
- React-Core/RCTSettingsHeaders (= 0.71.0)
- React-jsi (= 0.71.0)
- ReactCommon/turbomodule/core (= 0.71.0)
- React-RCTText (0.71.0):
- React-Core/RCTTextHeaders (= 0.71.0)
- React-RCTVibration (0.71.0):
- RCTTypeSafety (= 0.71.1)
- React-Codegen (= 0.71.1)
- React-Core/RCTSettingsHeaders (= 0.71.1)
- React-jsi (= 0.71.1)
- ReactCommon/turbomodule/core (= 0.71.1)
- React-RCTText (0.71.1):
- React-Core/RCTTextHeaders (= 0.71.1)
- React-RCTVibration (0.71.1):
- RCT-Folly (= 2021.07.22.00)
- React-Codegen (= 0.71.0)
- React-Core/RCTVibrationHeaders (= 0.71.0)
- React-jsi (= 0.71.0)
- ReactCommon/turbomodule/core (= 0.71.0)
- React-runtimeexecutor (0.71.0):
- React-jsi (= 0.71.0)
- ReactCommon/turbomodule/bridging (0.71.0):
- React-Codegen (= 0.71.1)
- React-Core/RCTVibrationHeaders (= 0.71.1)
- React-jsi (= 0.71.1)
- ReactCommon/turbomodule/core (= 0.71.1)
- React-runtimeexecutor (0.71.1):
- React-jsi (= 0.71.1)
- ReactCommon/turbomodule/bridging (0.71.1):
- DoubleConversion
- glog
- RCT-Folly (= 2021.07.22.00)
- React-callinvoker (= 0.71.0)
- React-Core (= 0.71.0)
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-logger (= 0.71.0)
- React-perflogger (= 0.71.0)
- ReactCommon/turbomodule/core (0.71.0):
- React-callinvoker (= 0.71.1)
- React-Core (= 0.71.1)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-logger (= 0.71.1)
- React-perflogger (= 0.71.1)
- ReactCommon/turbomodule/core (0.71.1):
- DoubleConversion
- glog
- RCT-Folly (= 2021.07.22.00)
- React-callinvoker (= 0.71.0)
- React-Core (= 0.71.0)
- React-cxxreact (= 0.71.0)
- React-jsi (= 0.71.0)
- React-logger (= 0.71.0)
- React-perflogger (= 0.71.0)
- React-callinvoker (= 0.71.1)
- React-Core (= 0.71.1)
- React-cxxreact (= 0.71.1)
- React-jsi (= 0.71.1)
- React-logger (= 0.71.1)
- React-perflogger (= 0.71.1)
- rn-fetch-blob (0.12.0):
- React-Core
- RNBackgroundFetch (4.1.8):
@ -346,9 +361,13 @@ PODS:
- React-Core
- RNCClipboard (1.11.1):
- React-Core
- RNFastImage (8.6.3):
- React-Core
- SDWebImage (~> 5.11.1)
- SDWebImageWebPCoder (~> 0.8.4)
- RNFS (2.20.0):
- React-Core
- RNGestureHandler (2.8.0):
- RNGestureHandler (2.9.0):
- React-Core
- RNImageCropPicker (0.38.1):
- React-Core
@ -361,14 +380,16 @@ PODS:
- TOCropViewController
- RNInAppBrowser (3.7.0):
- React-Core
- RNNotifee (7.4.0):
- RNNotifee (7.5.0):
- React-Core
- RNNotifee/NotifeeCore (= 7.4.0)
- RNNotifee/NotifeeCore (7.4.0):
- RNNotifee/NotifeeCore (= 7.5.0)
- RNNotifee/NotifeeCore (7.5.0):
- React-Core
- RNPermissions (3.6.1):
- React-Core
- RNReactNativeHapticFeedback (1.14.0):
- React-Core
- RNReanimated (2.13.0):
- RNReanimated (2.14.4):
- DoubleConversion
- FBLazyVector
- FBReactNativeSpec
@ -395,12 +416,18 @@ PODS:
- React-RCTText
- ReactCommon/turbomodule/core
- Yoga
- RNScreens (3.18.2):
- RNScreens (3.20.0):
- React-Core
- React-RCTImage
- RNSVG (12.5.0):
- RNSVG (12.5.1):
- React-Core
- segment-analytics-react-native (2.10.1):
- SDWebImage (5.11.1):
- SDWebImage/Core (= 5.11.1)
- SDWebImage/Core (5.11.1)
- SDWebImageWebPCoder (0.8.5):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.10)
- segment-analytics-react-native (2.13.0):
- React-Core
- sovran-react-native
- sovran-react-native (0.4.5):
@ -418,6 +445,8 @@ DEPENDENCIES:
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- libevent (~> 2.1.12)
- Permission-Camera (from `../node_modules/react-native-permissions/ios/Camera`)
- Permission-PhotoLibrary (from `../node_modules/react-native-permissions/ios/PhotoLibrary`)
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`)
- RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
@ -441,6 +470,7 @@ DEPENDENCIES:
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-splash-screen (from `../node_modules/react-native-splash-screen`)
- react-native-version-number (from `../node_modules/react-native-version-number`)
- react-native-webview (from `../node_modules/react-native-webview`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
- React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
@ -458,11 +488,13 @@ DEPENDENCIES:
- RNBackgroundFetch (from `../node_modules/react-native-background-fetch`)
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
- RNFastImage (from `../node_modules/react-native-fast-image`)
- RNFS (from `../node_modules/react-native-fs`)
- 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`)
- "RNNotifee (from `../node_modules/@notifee/react-native`)"
- RNPermissions (from `../node_modules/react-native-permissions`)
- RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
@ -475,6 +507,9 @@ SPEC REPOS:
trunk:
- fmt
- libevent
- libwebp
- SDWebImage
- SDWebImageWebPCoder
- Swime
- TOCropViewController
@ -493,6 +528,10 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec"
hermes-engine:
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
Permission-Camera:
:path: "../node_modules/react-native-permissions/ios/Camera"
Permission-PhotoLibrary:
:path: "../node_modules/react-native-permissions/ios/PhotoLibrary"
RCT-Folly:
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
RCTRequired:
@ -537,6 +576,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-splash-screen"
react-native-version-number:
:path: "../node_modules/react-native-version-number"
react-native-webview:
:path: "../node_modules/react-native-webview"
React-perflogger:
:path: "../node_modules/react-native/ReactCommon/reactperflogger"
React-RCTActionSheet:
@ -571,6 +612,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-async-storage/async-storage"
RNCClipboard:
:path: "../node_modules/@react-native-clipboard/clipboard"
RNFastImage:
:path: "../node_modules/react-native-fast-image"
RNFS:
:path: "../node_modules/react-native-fs"
RNGestureHandler:
@ -581,6 +624,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-inappbrowser-reborn"
RNNotifee:
:path: "../node_modules/@notifee/react-native"
RNPermissions:
:path: "../node_modules/react-native-permissions"
RNReactNativeHapticFeedback:
:path: "../node_modules/react-native-haptic-feedback"
RNReanimated:
@ -597,69 +642,77 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
boost: a7c83b31436843459a1961bfd74b96033dc77234
boost: 57d2868c099736d80fcd648bf211b4431e51a558
BVLinearGradient: 34a999fda29036898a09c6a6b728b0b4189e1a44
DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662
FBLazyVector: 61839cba7a48c570b7ac3e1cd8a4d0948382202f
FBReactNativeSpec: 5a14398ccf5e27c1ca2d7109eb920594ce93c10d
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
FBLazyVector: ad72713385db5289b19f1ead07e8e4aa26dcb01d
FBReactNativeSpec: df2602c11e33d310433496e28a48b4b2be652a61
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: 476ee3e89abb49e07f822b48323c51c57124b572
hermes-engine: f6e715aa6c8bd38de6c13bc85e07b0a337edaa89
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
hermes-engine: 922ccd744f50d9bfde09e9677bf0f3b562ea5fb9
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6
Permission-PhotoLibrary: 5b34ca67279f7201ae109cef36f9806a6596002d
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
RCTRequired: dea3e4163184ea57c50288c15c32c1529265c58f
RCTTypeSafety: a0834ab89159a346731e8aae55ad6e2cce61c327
React: d877d055ff2137ca0325a4babdef3411e11f3cb7
React-callinvoker: 77bd2701eee3acac154b11ec219e68d5a1f780ad
React-Codegen: bccc516adc1551ccfe04b0de27e345d38829b204
React-Core: 4035f59e5bec8f3053583c6108d99c7516deb760
React-CoreModules: b6a1f76423fea57a03e0d7a2f79d3b55cf193f2c
React-cxxreact: fe5f6ec8ae875bebc71309d1e8ef89bb966d61a6
React-hermes: 3c8ea5e8f402db2a08b57051206d7f2ba9c75565
React-jsi: dbf0f82c93bfd828fa05c50f2ee74dc81f711050
React-jsiexecutor: 060dd495f1e2af3d87216f7ca8a94c55ec885b4f
React-jsinspector: 5061fcbec93fd672183dfb39cc2f65e55a0835db
React-logger: a6c0b3a807a8e81f6d7fea2e72660766f55daa50
RCTRequired: fd4d923b964658aa0c4091a32c8b2004c6d9e3a6
RCTTypeSafety: c276d85975bde3d8448907235c70bf0da257adfd
React: e481a67971af1ce9639c9f746b753dd0e84ca108
React-callinvoker: 1051c04a94fa9d243786b86380606bad701a3b31
React-Codegen: 14b1e716d361d5ad95e0ce1a338f3fa0733a98b5
React-Core: 698fc3baecb80d511d987475a16d036cec6d287f
React-CoreModules: 59245305f41ff0adfeac334acc0594dea4585a7c
React-cxxreact: 49accd2954b0f532805dbcd1918fa6962f32f247
React-hermes: d068733294581a085e95b6024e8d951b005e26d3
React-jsi: 122b9bce14f4c6c7cb58f28f87912cfe091885fa
React-jsiexecutor: 60cf272aababc5212410e4249d17cea14fc36caa
React-jsinspector: ff56004b0c974b688a6548c156d5830ad751ae07
React-logger: 60a0b5f8bed667ecf9e24fecca1f30d125de6d75
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
react-native-cameraroll: 71d68167beb6fc7216aa564abb6d86f1d666a2c6
react-native-image-resizer: 794abf75ec13ed1f0dbb1f134e27504ea65e9e66
react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43
react-native-paste-input: 5182843692fd2ec72be50f241a38a49796e225d7
react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a
react-native-cameraroll: cb752fda6d5268f1646b4390bd5be1f27706b9a0
react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa
react-native-pager-view: b58cb9e9f42f64e50cab3040815772c1d119a2e2
react-native-paste-input: 3392800944a47c00dddbff23c31c281482209679
react-native-safe-area-context: 39c2d8be3328df5d437ac1700f4f3a4f75716acc
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f
React-perflogger: e5fc4149e9bbb972b8520277f3b23141faa47a36
React-RCTActionSheet: 991de88216bf03ab9bb1d213d73c62ecbe64ade7
React-RCTAnimation: b74e3d1bf5280891a573e447b487fa1db0713b5b
React-RCTAppDelegate: f52667f2dbc510f87b7988c5204e8764d50bf0c1
React-RCTBlob: 6762787c01d5d8d18efed03764b0d58d3b79595a
React-RCTImage: 9ed7eba8dd192a49def2cad2ecaedee7e7e315b4
React-RCTLinking: 0b58eed9af0645a161b80bf412b6b721e4585c66
React-RCTNetwork: dc075b0eea00d8a98c928f011d9bc2458acc7092
React-RCTSettings: 30fb3f498cfaf8a4bb47334ff9ffbe318ef78766
React-RCTText: a631564e84a227fe24bae7c04446f36faea7fcf5
React-RCTVibration: 55c91eccdbd435d7634efbe847086944389475b0
React-runtimeexecutor: ac80782d9d76ba2b0f709f4de0c427fe33c352dc
ReactCommon: 20e38a9be5fe1341b5e422220877cc94034776ba
react-native-webview: 9f111dfbcfc826084d6c507f569e5e03342ee1c1
React-perflogger: ec8eef2a8f03ecfa6361c2c5fb9197ef4a29cc85
React-RCTActionSheet: a0c023b86cf4c862fa9c4eb0f6f91fbe878fb2de
React-RCTAnimation: 168d53718c74153947c0109f55900faa64d79439
React-RCTAppDelegate: a8efbab128b34aa07a9491c85a41401210b1bec5
React-RCTBlob: 9bcbfc893bfda9f6b2eb016329d38c0f6366d31a
React-RCTImage: 3fcd4570b4b0f1ac2f4b4b6308dba33ce66c5b50
React-RCTLinking: 1edb8e1bb3fc39bf9e13c63d6aaaa3f0c3d18683
React-RCTNetwork: 500a79e0e0f67678077df727fabba87a55c043e1
React-RCTSettings: cc4414eb84ad756d619076c3999fecbf12896d6f
React-RCTText: 2a34261f3da6e34f47a62154def657546ebfa5e1
React-RCTVibration: 49d531ec8498e0afa2c9b22c2205784372e3d4f3
React-runtimeexecutor: 311feb67600774723fe10eb8801d3138cae9ad67
ReactCommon: 03be76588338a27a88d103b35c3c44a3fd43d136
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3
RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
RNNotifee: da8dcf09f079ea22f46e239d7c406e10d4525a5f
RNNotifee: 053c0ace9c73634709a0214fd9c436a5777a562f
RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
RNReanimated: d8d9d3d3801bda5e35e85cdffc871577d044dc2e
RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
RNSVG: 6adc5c52d2488a476248413064b7f2832e639057
segment-analytics-react-native: cb097e393c3560a0d4cfd877044293e37b0050d9
RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128
RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
RNSVG: d7d7bc8229af3842c9cfc3a723c815a52cdd1105
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
segment-analytics-react-native: bd1f13ea95bad2313a9c7130da032af0e9a6da60
sovran-react-native: fd3dc8f1a4b14acdc4ad25fc6b4ac4f52a2a2a15
Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
Yoga: c618b544ff8bd8865cdca602f00cbcdb92fd6d31
Yoga: 921eb014669cf9c718ada68b08d362517d564e0c
PODFILE CHECKSUM: 0975a639c66f07f4d49706dd0bf7c3aa4dc833cf
PODFILE CHECKSUM: 95c7fde1130d862b561348cca2b3fb7f9bd84bfb
COCOAPODS: 1.11.3

View File

@ -21,6 +21,7 @@
[[TSBackgroundFetch sharedInstance] didFinishLaunching];
self.moduleName = @"xyz.blueskyweb.app";
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

View File

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>1.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -39,6 +39,8 @@
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>

View File

@ -1,10 +1,19 @@
/* global jest */
import {configure} from '@testing-library/react-native'
import 'react-native-gesture-handler/jestSetup'
configure({asyncUtilTimeout: 20000})
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')
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => {
const {EventEmitter} = require('events')
return {
__esModule: true,
default: EventEmitter,
}
})
// Silence the warning: Animated: `useNativeDriver` is not supported
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper')
@ -55,3 +64,7 @@ jest.mock('@segment/analytics-react-native', () => ({
flush: jest.fn(),
}),
}))
jest.mock('react-native-permissions', () =>
require('react-native-permissions/mock'),
)

View File

@ -8,7 +8,7 @@ import PDSServer, {
ServerConfig as PDSServerConfig,
} from '@atproto/pds'
import * as plc from '@atproto/plc'
import AtpApi, {ServiceClient} from '@atproto/api'
import AtpAgent from '@atproto/api'
export interface TestUser {
email: string
@ -16,7 +16,7 @@ export interface TestUser {
declarationCid: string
handle: string
password: string
api: ServiceClient
agent: AtpAgent
}
export interface TestUsers {
@ -87,6 +87,8 @@ export async function createServer(): Promise<TestPDS> {
dbPostgresUrl: process.env.DB_POSTGRES_URL,
blobstoreLocation: `${blobstoreLoc}/blobs`,
blobstoreTmp: `${blobstoreLoc}/tmp`,
maxSubscriptionBuffer: 200,
repoBackfillLimitMs: 1e3 * 60 * 60,
})
const db = PDSDatabase.memory()
@ -112,11 +114,11 @@ export async function createServer(): Promise<TestPDS> {
async function genMockData(pdsUrl: string): Promise<TestUsers> {
const date = dateGen()
const clients = {
loggedout: AtpApi.service(pdsUrl),
alice: AtpApi.service(pdsUrl),
bob: AtpApi.service(pdsUrl),
carla: AtpApi.service(pdsUrl),
const agents = {
loggedout: new AtpAgent({service: pdsUrl}),
alice: new AtpAgent({service: pdsUrl}),
bob: new AtpAgent({service: pdsUrl}),
carla: new AtpAgent({service: pdsUrl}),
}
const users: TestUser[] = [
{
@ -125,7 +127,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
declarationCid: '',
handle: 'alice.test',
password: 'hunter2',
api: clients.alice,
agent: agents.alice,
},
{
email: 'bob@test.com',
@ -133,7 +135,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
declarationCid: '',
handle: 'bob.test',
password: 'hunter2',
api: clients.bob,
agent: agents.bob,
},
{
email: 'carla@test.com',
@ -141,7 +143,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
declarationCid: '',
handle: 'carla.test',
password: 'hunter2',
api: clients.carla,
agent: agents.carla,
},
]
const alice = users[0]
@ -150,18 +152,18 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
let _i = 1
for (const user of users) {
const res = await clients.loggedout.com.atproto.account.create({
const res = await agents.loggedout.api.com.atproto.account.create({
email: user.email,
handle: user.handle,
password: user.password,
})
user.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
const {data: profile} = await user.api.app.bsky.actor.getProfile({
user.agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
const {data: profile} = await user.agent.api.app.bsky.actor.getProfile({
actor: user.handle,
})
user.did = res.data.did
user.declarationCid = profile.declaration.cid
await user.api.app.bsky.actor.profile.create(
await user.agent.api.app.bsky.actor.profile.create(
{did: user.did},
{
displayName: ucfirst(user.handle).slice(0, -5),
@ -172,7 +174,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
// everybody follows everybody
const follow = async (author: TestUser, subject: TestUser) => {
await author.api.app.bsky.graph.follow.create(
await author.agent.api.app.bsky.graph.follow.create(
{did: author.did},
{
subject: {

View File

@ -3,17 +3,15 @@ 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 {ThemeProvider} from '../src/view/lib/ThemeContext'
import {mockedRootStore} from '../__mocks__/state-mock'
import {RootStoreProvider, RootStoreModel} from '../src/state'
import {ThemeProvider} from '../src/lib/ThemeContext'
const customRender = (ui: any, rootStore?: any) =>
const customRender = (ui: any, rootStore: RootStoreModel) =>
render(
// eslint-disable-next-line react-native/no-inline-styles
<GestureHandlerRootView style={{flex: 1}}>
<RootSiblingParent>
<RootStoreProvider
value={rootStore != null ? rootStore : mockedRootStore}>
<RootStoreProvider value={rootStore}>
<ThemeProvider theme="light">
<SafeAreaProvider>{ui}</SafeAreaProvider>
</ThemeProvider>

View File

@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "0.0.1",
"version": "1.2.0",
"private": true,
"scripts": {
"android": "react-native run-android",
@ -8,16 +8,17 @@
"web": "webpack-dev-server --config ./web/webpack.config.js -d inline-source-map --hot --color",
"start": "react-native start",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
"test": "jest --forceExit",
"test": "jest --forceExit --testTimeout=20000 --bail",
"test-watch": "jest --watchAll",
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "jest --coverage",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"e2e": "detox test --configuration ios.sim.debug --take-screenshots all"
},
"dependencies": {
"@atproto/api": "^0.0.6",
"@atproto/api": "^0.1.2",
"@atproto/lexicon": "^0.0.4",
"@atproto/xrpc": "^0.0.3",
"@atproto/xrpc": "^0.0.4",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
@ -34,21 +35,27 @@
"@segment/analytics-react-native": "^2.10.1",
"@segment/sovran-react-native": "^0.4.5",
"@zxing/text-encoding": "^0.9.0",
"await-lock": "^2.2.2",
"base64-js": "^1.5.1",
"email-validator": "^2.0.4",
"he": "^1.2.0",
"lodash.chunk": "^4.2.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0",
"lodash.omit": "^4.5.0",
"lodash.shuffle": "^4.2.0",
"lru_map": "^0.4.1",
"mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0",
"normalize-url": "^8.0.0",
"react": "18.2.0",
"react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0",
"react-native": "0.71.0",
"react-native": "0.71.1",
"react-native-appstate-hook": "^1.0.6",
"react-native-background-fetch": "^4.1.8",
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.5.0",
"react-native-haptic-feedback": "^1.14.0",
@ -56,6 +63,7 @@
"react-native-inappbrowser-reborn": "^3.6.3",
"react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "^6.0.2",
"react-native-permissions": "^3.6.1",
"react-native-progress": "^5.0.0",
"react-native-reanimated": "^2.9.1",
"react-native-root-siblings": "^4.1.1",
@ -66,18 +74,22 @@
"react-native-svg": "^12.4.0",
"react-native-tab-view": "^3.3.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1",
"react-native-version-number": "^0.3.6",
"react-native-web": "^0.18.11",
"react-native-web-linear-gradient": "^1.1.2",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "^11.26.1",
"react-native-youtube-iframe": "^2.2.2",
"rn-fetch-blob": "^0.12.0",
"tlds": "^1.234.0",
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/pds": "^0.0.1",
"@babel/core": "^7.12.9",
"@babel/preset-env": "^7.14.0",
"@babel/runtime": "^7.12.5",
"@atproto/pds": "^0.0.3",
"@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
"@react-native-community/eslint-config": "^3.0.0",
"@testing-library/jest-native": "^5.3.3",
"@testing-library/react-native": "^11.5.0",
@ -85,7 +97,10 @@
"@types/he": "^1.1.2",
"@types/jest": "^26.0.23",
"@types/lodash.chunk": "^4.2.7",
"@types/lodash.clonedeep": "^4.5.7",
"@types/lodash.isequal": "^4.5.6",
"@types/lodash.omit": "^4.5.7",
"@types/lodash.shuffle": "^4.2.7",
"@types/react-avatar-editor": "^13.0.0",
"@types/react-native": "^0.67.3",
"@types/react-test-renderer": "^17.0.1",
@ -95,11 +110,14 @@
"babel-loader": "^9.1.2",
"babel-plugin-module-resolver": "^5.0.0",
"babel-plugin-react-native-web": "^0.18.12",
"detox": "^20.1.2",
"eslint": "^8.19.0",
"eslint-plugin-detox": "^1.0.0",
"eslint-plugin-ft-flow": "^2.0.3",
"html-webpack-plugin": "^5.5.0",
"jest": "^29.2.1",
"jest-junit": "^15.0.0",
"metro-react-native-babel-preset": "0.73.5",
"metro-react-native-babel-preset": "^0.73.7",
"prettier": "^2.8.3",
"react-native-dotenv": "^3.3.1",
"react-scripts": "^5.0.1",
@ -131,10 +149,11 @@
"node"
],
"transformIgnorePatterns": [
"node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|rollbar-react-native|@fortawesome|@react-native|@react-navigation)"
"node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|rollbar-react-native|@fortawesome|@react-native|@react-navigation|normalize-url)"
],
"modulePathIgnorePatterns": [
"__tests__/.*/__mocks__"
"__tests__/.*/__mocks__",
"e2e/.*"
],
"coveragePathIgnorePatterns": [
"<rootDir>/node_modules/",

View File

@ -6,35 +6,27 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'
import SplashScreen from 'react-native-splash-screen'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {observer} from 'mobx-react-lite'
import {
createClient,
SegmentClient,
AnalyticsProvider,
} from '@segment/analytics-react-native'
import {ThemeProvider} from './view/lib/ThemeContext'
import {ThemeProvider} from 'lib/ThemeContext'
import * as view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {MobileShell} from './view/shell/mobile'
import {s} from './view/lib/styles'
import notifee, {EventType} from '@notifee/react-native'
import {s} from 'lib/styles'
import * as notifee from 'lib/notifee'
import * as analytics from 'lib/analytics'
import * as Toast from './view/com/util/Toast'
const App = observer(() => {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
const [segment, setSegment] = useState<SegmentClient | undefined>(undefined)
// init
useEffect(() => {
view.setup()
setSegment(
createClient({
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
trackAppLifecycleEvents: true,
}),
)
setupState().then(store => {
setRootStore(store)
analytics.init(store)
notifee.init(store)
SplashScreen.hide()
Linking.getInitialURL().then((url: string | null) => {
if (url) {
@ -44,12 +36,8 @@ const App = observer(() => {
Linking.addEventListener('url', ({url}) => {
store.nav.handleLink(url)
})
notifee.onForegroundEvent(async ({type}: {type: EventType}) => {
store.log.debug('Notifee foreground event', {type})
if (type === EventType.PRESS) {
store.log.debug('User pressed a notifee, opening notifications')
store.nav.switchTo(1, true)
}
store.onSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
})
}, [])
@ -58,20 +46,19 @@ const App = observer(() => {
if (!rootStore) {
return null
}
return (
<GestureHandlerRootView style={s.h100pct}>
<RootSiblingParent>
<AnalyticsProvider client={segment}>
<RootStoreProvider value={rootStore}>
<ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<SafeAreaProvider>
<MobileShell />
</SafeAreaProvider>
</ThemeProvider>
</RootStoreProvider>
</AnalyticsProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</GestureHandlerRootView>
)
})

View File

@ -53,6 +53,7 @@ export type TypographyVariant =
| 'xs-medium'
| 'xs-bold'
| 'xs-heavy'
| 'title-2xl'
| 'title-xl'
| 'title-lg'
| 'title'
@ -60,6 +61,7 @@ export type TypographyVariant =
| 'post-text-lg'
| 'post-text'
| 'button'
| 'button-lg'
| 'mono'
export type Typography = Record<TypographyVariant, TextStyle>

View File

@ -0,0 +1,74 @@
import React from 'react'
import {AppState, AppStateStatus} from 'react-native'
import {createClient, AnalyticsProvider} from '@segment/analytics-react-native'
import {RootStoreModel, AppInfo} from 'state/models/root-store'
const segmentClient = createClient({
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
trackAppLifecycleEvents: false,
})
export {useAnalytics} from '@segment/analytics-react-native'
export function init(store: RootStoreModel) {
// NOTE
// this method is a copy of segment's own lifecycle event tracking
// we handle it manually to ensure that it never fires while the app is backgrounded
// -prf
segmentClient.onContextLoaded(() => {
if (AppState.currentState !== 'active') {
store.log.debug('Prevented a metrics ping while the app was backgrounded')
return
}
const context = segmentClient.context.get()
if (typeof context?.app === 'undefined') {
store.log.debug('Aborted metrics ping due to unavailable context')
return
}
const oldAppInfo = store.appInfo
const newAppInfo = context.app as AppInfo
store.setAppInfo(newAppInfo)
store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
if (typeof oldAppInfo === 'undefined') {
segmentClient.track('Application Installed', {
version: newAppInfo.version,
build: newAppInfo.build,
})
} else if (newAppInfo.version !== oldAppInfo.version) {
segmentClient.track('Application Updated', {
version: newAppInfo.version,
build: newAppInfo.build,
previous_version: oldAppInfo.version,
previous_build: oldAppInfo.build,
})
}
segmentClient.track('Application Opened', {
from_background: false,
version: newAppInfo.version,
build: newAppInfo.build,
})
})
let lastState: AppStateStatus = AppState.currentState
AppState.addEventListener('change', (state: AppStateStatus) => {
if (state === 'active' && lastState !== 'active') {
const context = segmentClient.context.get()
segmentClient.track('Application Opened', {
from_background: true,
version: context?.app?.version,
build: context?.app?.build,
})
} else if (state !== 'active' && lastState === 'active') {
segmentClient.track('Application Backgrounded')
}
lastState = state
})
}
export function Provider({children}: React.PropsWithChildren<{}>) {
return (
<AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider>
)
}

View File

@ -0,0 +1,16 @@
// TODO
import React from 'react'
import {RootStoreModel} from 'state/models/root-store'
export function useAnalytics() {
return {
screen(_name: string) {},
track(_name: string, _opts: any) {},
}
}
export function init(_store: RootStoreModel) {}
export function Provider({children}: React.PropsWithChildren<{}>) {
return children
}

View File

@ -1,10 +1,10 @@
import {sessionClient as AtpApi} from '@atproto/api'
import AtpAgent from '@atproto/api'
import RNFS from 'react-native-fs'
const TIMEOUT = 10e3 // 10s
export function doPolyfill() {
AtpApi.xrpc.fetch = fetchHandler
AtpAgent.configure({fetch: fetchHandler})
}
interface FetchHandlerResponse {

View File

@ -1,16 +1,11 @@
/**
* The environment is a place where services and shared dependencies between
* models live. They are made available to every model via dependency injection.
*/
// import {ReactNativeStore} from './auth'
import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from '../models/root-store'
import {extractEntities} from '../../lib/strings'
import {isNetworkError} from '../../lib/errors'
import {LinkMeta} from '../../lib/link-meta'
import {Image} from '../../lib/images'
import {RootStoreModel} from 'state/models/root-store'
import {extractEntities} from 'lib/strings/rich-text-detection'
import {isNetworkError} from 'lib/strings/errors'
import {LinkMeta} from '../link-meta/link-meta'
import {Image} from '../images'
import {RichText} from '../strings/rich-text'
export interface ExternalEmbedDraft {
uri: string
@ -19,9 +14,22 @@ export interface ExternalEmbedDraft {
localThumb?: Image
}
export async function resolveName(store: RootStoreModel, didOrHandle: string) {
if (!didOrHandle) {
throw new Error('Invalid handle: ""')
}
if (didOrHandle.startsWith('did:')) {
return didOrHandle
}
const res = await store.api.com.atproto.handle.resolve({
handle: didOrHandle,
})
return res.data.did
}
export async function post(
store: RootStoreModel,
text: string,
rawText: string,
replyTo?: string,
extLink?: ExternalEmbedDraft,
images?: string[],
@ -30,6 +38,9 @@ export async function post(
) {
let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined
let reply
const text = new RichText(rawText, undefined, {
cleanNewlines: true,
}).text.trim()
onStateChange?.('Processing...')
const entities = extractEntities(text, knownHandles)

View File

@ -0,0 +1,4 @@
import VersionNumber from 'react-native-version-number'
export const appVersion = VersionNumber.appVersion
export const buildVersion = VersionNumber.buildVersion

View File

@ -0,0 +1,3 @@
// TODO
export const appVersion = 'TODO'
export const buildVersion = 'TODO'

View File

@ -0,0 +1,5 @@
import {ImageRequireSource} from 'react-native'
export const DEF_AVATAR: ImageRequireSource = require('../../public/img/default-avatar.jpg')
export const TABS_EXPLAINER: ImageRequireSource = require('../../public/img/tabs-explainer.jpg')
export const CLOUD_SPLASH: ImageRequireSource = require('../../public/img/cloud-splash.png')

10
src/lib/assets.ts 100644
View File

@ -0,0 +1,10 @@
import {ImageRequireSource} from 'react-native'
// @ts-ignore we need to pretend -prf
export const DEF_AVATAR: ImageRequireSource = {uri: '/img/default-avatar.jpg'}
// @ts-ignore we need to pretend -prf
export const TABS_EXPLAINER: ImageRequireSource = {
uri: '/img/tabs-explainer.jpg',
}
// @ts-ignore we need to pretend -prf
export const CLOUD_SPLASH: ImageRequireSource = {uri: '/img/cloud-splash.png'}

View File

@ -0,0 +1,24 @@
type BundledFn<Args extends readonly unknown[], Res> = (
...args: Args
) => Promise<Res>
/**
* A helper which ensures that multiple calls to an async function
* only produces one in-flight request at a time.
*/
export function bundleAsync<Args extends readonly unknown[], Res>(
fn: BundledFn<Args, Res>,
): BundledFn<Args, Res> {
let promise: Promise<Res> | undefined
return async (...args) => {
if (promise) {
return promise
}
promise = fn(...args)
try {
return await promise
} finally {
promise = undefined
}
}
}

View File

@ -4,7 +4,7 @@ import BackgroundFetch, {
export function configure(
handler: (taskId: string) => Promise<void>,
timeoutHandler: (taskId: string) => Promise<void>,
timeoutHandler: (taskId: string) => void,
): Promise<BackgroundFetchStatus> {
return BackgroundFetch.configure(
{minimumFetchInterval: 15},

View File

@ -0,0 +1,65 @@
export const FEEDBACK_FORM_URL =
'https://docs.google.com/forms/d/e/1FAIpQLSdavFRXTdB6tRobaFrRR2A1gv3b-IBHwQkBmNZTRpoqmcrPrQ/viewform?usp=sf_link'
export const MAX_DISPLAY_NAME = 64
export const MAX_DESCRIPTION = 256
export const PROD_SUGGESTED_FOLLOWS = [
'john',
'visakanv',
'saz',
'steph',
'ratzlaff',
'beth',
'weisser',
'katherine',
'annagat',
'josh',
'lurkshark',
'amir',
'amyxzh',
'danielle',
'jack-frazee',
'vibes',
'cat',
'yuriy',
'alvinreyes',
'skoot',
'patricia',
'ara4n',
'case',
'armand',
'ivan',
'nicholas',
'kelsey',
'ericlee',
'emily',
'jake',
'jennijuju',
'ian5v',
'bnewbold',
'chris',
'mtclai',
'willscott',
'michael',
'kwkroeger',
'broox',
'iamrosewang',
'jack-morrison',
'pwang',
'martin',
'jack',
'dan',
'why',
'divy',
'jay',
'paul',
].map(handle => `${handle}.bsky.social`)
export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map(
handle => `${handle}.staging.bsky.dev`,
)
export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map(
handle => `${handle}.test`,
)

View File

@ -1,4 +0,0 @@
export function isNetworkError(e: unknown) {
const str = String(e)
return str.includes('Abort') || str.includes('Network request failed')
}

View File

@ -1,6 +1,6 @@
import {useState} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {RootStoreModel} from '../../../state'
import {RootStoreModel} from 'state/index'
export type OnScrollCb = (
event: NativeSyntheticEvent<NativeScrollEvent>,

View File

@ -2,8 +2,8 @@ import RNFetchBlob from 'rn-fetch-blob'
import ImageResizer from '@bam.tech/react-native-image-resizer'
import {Share} from 'react-native'
import RNFS from 'react-native-fs'
import * as Toast from '../view/com/util/Toast'
import uuid from 'react-native-uuid'
import * as Toast from 'view/com/util/Toast'
export interface DownloadAndResizeOpts {
uri: string
@ -23,16 +23,12 @@ export interface Image {
}
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
let appendExt
let appendExt = 'jpeg'
try {
const urip = new URL(opts.uri)
const ext = urip.pathname.split('.').pop()
if (ext === 'jpg' || ext === 'jpeg') {
appendExt = 'jpeg'
} else if (ext === 'png') {
if (ext === 'png') {
appendExt = 'png'
} else {
return
}
} catch (e: any) {
console.error('Invalid URI', opts.uri, e)
@ -109,12 +105,18 @@ export async function compressIfNeeded(
if (img.size < maxSize) {
return img
}
return await resize(origUri, {
const resizedImage = await resize(origUri, {
width: img.width,
height: img.height,
mode: 'stretch',
maxSize,
})
const finalImageMovedPath = await moveToPremanantPath(resizedImage.path)
const finalImg = {
...resizedImage,
path: finalImageMovedPath,
}
return finalImg
}
export interface Dim {
@ -150,3 +152,15 @@ export const saveImageModal = async ({uri}: {uri: string}) => {
}
RNFS.unlink(imagePath)
}
export const moveToPremanantPath = async (path: string) => {
/*
Since this package stores images in a temp directory, we need to move the file to a permanent location.
Relevant: IOS bug when trying to open a second time:
https://github.com/ivpusic/react-native-image-crop-picker/issues/1199
*/
const filename = uuid.v4()
const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}`
RNFS.moveFile(path, destinationPath)
return destinationPath
}

View File

@ -1,6 +1,5 @@
import {Share} from 'react-native'
import * as Toast from '../view/com/util/Toast'
// import {Share} from 'react-native'
// import * as Toast from 'view/com/util/Toast'
export interface DownloadAndResizeOpts {
uri: string

View File

@ -1,18 +1,18 @@
import {LikelyType, LinkMeta} from './link-meta'
import {match as matchRoute} from '../view/routes'
import {convertBskyAppUrlIfNeeded, makeRecordUri} from './strings'
import {RootStoreModel} from '../state'
import {PostThreadViewModel} from '../state/models/post-thread-view'
import {match as matchRoute} from 'view/routes'
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
import {RootStoreModel} from 'state/index'
import {PostThreadViewModel} from 'state/models/post-thread-view'
import {Home} from '../view/screens/Home'
import {Search} from '../view/screens/Search'
import {Notifications} from '../view/screens/Notifications'
import {PostThread} from '../view/screens/PostThread'
import {PostUpvotedBy} from '../view/screens/PostUpvotedBy'
import {PostRepostedBy} from '../view/screens/PostRepostedBy'
import {Profile} from '../view/screens/Profile'
import {ProfileFollowers} from '../view/screens/ProfileFollowers'
import {ProfileFollows} from '../view/screens/ProfileFollows'
import {Home} from 'view/screens/Home'
import {Search} from 'view/screens/Search'
import {Notifications} from 'view/screens/Notifications'
import {PostThread} from 'view/screens/PostThread'
import {PostUpvotedBy} from 'view/screens/PostUpvotedBy'
import {PostRepostedBy} from 'view/screens/PostRepostedBy'
import {Profile} from 'view/screens/Profile'
import {ProfileFollowers} from 'view/screens/ProfileFollowers'
import {ProfileFollows} from 'view/screens/ProfileFollows'
// NOTE
// this is a hack around the lack of hosted social metadata

View File

@ -1,5 +1,5 @@
import {extractTwitterMeta} from './extractTwitterMeta'
import {extractYoutubeMeta} from './extractYoutubeMeta'
import {extractTwitterMeta} from './twitter'
import {extractYoutubeMeta} from './youtube'
interface ExtractHtmlMetaInput {
html: string

View File

@ -1,8 +1,8 @@
import he from 'he'
import {isBskyAppUrl} from './strings'
import {RootStoreModel} from '../state'
import {extractBskyMeta} from './extractBskyMeta'
import {extractHtmlMeta} from './extractHtmlMeta'
import {isBskyAppUrl} from '../strings/url-helpers'
import {RootStoreModel} from 'state/index'
import {extractBskyMeta} from './bsky'
import {extractHtmlMeta} from './html'
export enum LikelyType {
HTML,

View File

@ -4,10 +4,12 @@ export const extractYoutubeMeta = (html: string): Record<string, string> => {
const youtubeDescriptionRegex =
/"videoDetails":.*"shortDescription":"([^"]*)"/i
const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i
const youtubeAvatarRegex =
/"avatar":{"thumbnails":\[{.*?url.*?url.*?url":"([^"]*)"/i
const youtubeTitleMatch = youtubeTitleRegex.exec(html)
const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html)
const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html)
const youtubeAvatarMatch = youtubeAvatarRegex.exec(html)
if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) {
res.title = decodeURI(youtubeTitleMatch[1])
@ -21,6 +23,9 @@ export const extractYoutubeMeta = (html: string): Record<string, string> => {
if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) {
res.image = youtubeThumbnailMatch[1] + 'default.jpg'
}
if (!res.image && youtubeAvatarMatch && youtubeAvatarMatch.length >= 1) {
res.image = youtubeAvatarMatch[1]
}
return res
}

View File

@ -1,7 +1,26 @@
import notifee from '@notifee/react-native'
import notifee, {EventType} from '@notifee/react-native'
import {AppBskyEmbedImages} from '@atproto/api'
import {NotificationsViewItemModel} from '../../state/models/notifications-view'
import {enforceLen} from '../../lib/strings'
import {RootStoreModel} from 'state/models/root-store'
import {TabPurpose} from 'state/models/navigation'
import {NotificationsViewItemModel} from 'state/models/notifications-view'
import {enforceLen} from 'lib/strings/helpers'
export function init(store: RootStoreModel) {
store.onUnreadNotifications(count => notifee.setBadgeCount(count))
store.onPushNotification(displayNotificationFromModel)
store.onSessionLoaded(() => {
// request notifications permission once the user has logged in
notifee.requestPermission()
})
notifee.onForegroundEvent(async ({type}: {type: EventType}) => {
store.log.debug('Notifee foreground event', {type})
if (type === EventType.PRESS) {
store.log.debug('User pressed a notifee, opening notifications')
store.nav.switchTo(TabPurpose.Notifs, true)
}
})
notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent
}
export function displayNotification(
title: string,
@ -39,7 +58,8 @@ export function displayNotificationFromModel(
title = `${author} replied to your post`
body = notif.additionalPost?.thread?.postRecord?.text || ''
} else if (notif.isFollow) {
title = `${author} followed you`
title = 'New follower!'
body = `${author} has followed you`
} else {
return
}

View File

@ -0,0 +1,61 @@
import {Alert} from 'react-native'
import {
check,
openSettings,
Permission,
PermissionStatus,
PERMISSIONS,
RESULTS,
} from 'react-native-permissions'
export const PHOTO_LIBRARY = PERMISSIONS.IOS.PHOTO_LIBRARY
export const CAMERA = PERMISSIONS.IOS.CAMERA
/**
* Returns `true` if the user has granted permission or hasn't made
* a decision yet. Returns `false` if unavailable or not granted.
*/
export async function hasAccess(perm: Permission): Promise<boolean> {
const status = await check(perm)
return isntANo(status)
}
export async function requestAccessIfNeeded(
perm: Permission,
): Promise<boolean> {
if (await hasAccess(perm)) {
return true
}
let permDescription
if (perm === PHOTO_LIBRARY) {
permDescription = 'photo library'
} else if (perm === CAMERA) {
permDescription = 'camera'
} else {
return false
}
Alert.alert(
'Permission needed',
`Bluesky does not have permission to access your ${permDescription}.`,
[
{
text: 'Cancel',
style: 'cancel',
},
{text: 'Open Settings', onPress: () => openSettings()},
],
)
return false
}
export async function requestPhotoAccessIfNeeded() {
return requestAccessIfNeeded(PHOTO_LIBRARY)
}
export async function requestCameraAccessIfNeeded() {
return requestAccessIfNeeded(CAMERA)
}
function isntANo(status: PermissionStatus): boolean {
return status !== RESULTS.UNAVAILABLE && status !== RESULTS.BLOCKED
}

View File

@ -0,0 +1,22 @@
/*
At the moment, Web doesn't have any equivalence for these.
*/
export const PHOTO_LIBRARY = ''
export const CAMERA = ''
export async function hasAccess(_perm: any): Promise<boolean> {
return true
}
export async function requestAccessIfNeeded(_perm: any): Promise<boolean> {
return true
}
export async function requestPhotoAccessIfNeeded() {
return requestAccessIfNeeded(PHOTO_LIBRARY)
}
export async function requestCameraAccessIfNeeded() {
return requestAccessIfNeeded(CAMERA)
}

View File

@ -1,267 +0,0 @@
import {AtUri} from '../third-party/uri'
import {AppBskyFeedPost} from '@atproto/api'
type Entity = AppBskyFeedPost.Entity
import {PROD_SERVICE} from '../state'
import {isNetworkError} from './errors'
import TLDs from 'tlds'
export const MAX_DISPLAY_NAME = 64
export const MAX_DESCRIPTION = 256
export function pluralize(n: number, base: string, plural?: string): string {
if (n === 1) {
return base
}
if (plural) {
return plural
}
return base + 's'
}
export function makeRecordUri(
didOrName: string,
collection: string,
rkey: string,
) {
const urip = new AtUri('at://host/')
urip.host = didOrName
urip.collection = collection
urip.rkey = rkey
return urip.toString()
}
const MINUTE = 60
const HOUR = MINUTE * 60
const DAY = HOUR * 24
const MONTH = DAY * 30
const YEAR = DAY * 365
export function ago(date: number | string | Date): string {
let ts: number
if (typeof date === 'string') {
ts = Number(new Date(date))
} else if (date instanceof Date) {
ts = Number(date)
} else {
ts = date
}
const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
if (diffSeconds < MINUTE) {
return `${diffSeconds}s`
} else if (diffSeconds < HOUR) {
return `${Math.floor(diffSeconds / MINUTE)}m`
} else if (diffSeconds < DAY) {
return `${Math.floor(diffSeconds / HOUR)}h`
} else if (diffSeconds < MONTH) {
return `${Math.floor(diffSeconds / DAY)}d`
} else if (diffSeconds < YEAR) {
return `${Math.floor(diffSeconds / MONTH)}mo`
} else {
return new Date(ts).toLocaleDateString()
}
}
export function isValidDomain(str: string): boolean {
return !!TLDs.find(tld => {
let i = str.lastIndexOf(tld)
if (i === -1) {
return false
}
return str.charAt(i - 1) === '.' && i === str.length - tld.length
})
}
export function extractEntities(
text: string,
knownHandles?: Set<string>,
): Entity[] | undefined {
let match
let ents: Entity[] = []
{
// mentions
const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g
while ((match = re.exec(text))) {
if (knownHandles && !knownHandles.has(match[3])) {
continue // not a known handle
} else if (!match[3].includes('.')) {
continue // probably not a handle
}
const start = text.indexOf(match[3], match.index) - 1
ents.push({
type: 'mention',
value: match[3],
index: {start, end: start + match[3].length + 1},
})
}
}
{
// links
const re =
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
while ((match = re.exec(text))) {
let value = match[2]
if (!value.startsWith('http')) {
const domain = match.groups?.domain
if (!domain || !isValidDomain(domain)) {
continue
}
value = `https://${value}`
}
const start = text.indexOf(match[2], match.index)
const index = {start, end: start + match[2].length}
// strip ending puncuation
if (/[.,;!?]$/.test(value)) {
value = value.slice(0, -1)
index.end--
}
if (/[)]$/.test(value) && !value.includes('(')) {
value = value.slice(0, -1)
index.end--
}
ents.push({
type: 'link',
value,
index,
})
}
}
return ents.length > 0 ? ents : undefined
}
interface DetectedLink {
link: string
}
type DetectedLinkable = string | DetectedLink
export function detectLinkables(text: string): DetectedLinkable[] {
const re =
/((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi
const segments = []
let match
let start = 0
while ((match = re.exec(text))) {
let matchIndex = match.index
let matchValue = match[0]
if (match.groups?.domain && !isValidDomain(match.groups?.domain)) {
continue
}
if (/\s|\(/.test(matchValue)) {
// HACK
// skip the starting space
// we have to do this because RN doesnt support negative lookaheads
// -prf
matchIndex++
matchValue = matchValue.slice(1)
}
// strip ending puncuation
if (/[.,;!?]$/.test(matchValue)) {
matchValue = matchValue.slice(0, -1)
}
if (/[)]$/.test(matchValue) && !matchValue.includes('(')) {
matchValue = matchValue.slice(0, -1)
}
if (start !== matchIndex) {
segments.push(text.slice(start, matchIndex))
}
segments.push({link: matchValue})
start = matchIndex + matchValue.length
}
if (start < text.length) {
segments.push(text.slice(start))
}
return segments
}
export function makeValidHandle(str: string): string {
if (str.length > 20) {
str = str.slice(0, 20)
}
str = str.toLowerCase()
return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
}
export function createFullHandle(name: string, domain: string): string {
name = (name || '').replace(/[.]+$/, '')
domain = (domain || '').replace(/^[.]+/, '')
return `${name}.${domain}`
}
export function enforceLen(str: string, len: number, ellipsis = false): string {
str = str || ''
if (str.length > len) {
return str.slice(0, len) + (ellipsis ? '...' : '')
}
return str
}
export function cleanError(str: any): string {
if (!str) {
return str
}
if (typeof str !== 'string') {
str = str.toString()
}
if (isNetworkError(str)) {
return 'Unable to connect. Please check your internet connection and try again.'
}
if (str.startsWith('Error: ')) {
return str.slice('Error: '.length)
}
return str
}
export function toNiceDomain(url: string): string {
try {
const urlp = new URL(url)
if (`https://${urlp.host}` === PROD_SERVICE) {
return 'Bluesky Social'
}
return urlp.host
} catch (e) {
return url
}
}
export function toShortUrl(url: string): string {
try {
const urlp = new URL(url)
const shortened =
urlp.host +
(urlp.pathname === '/' ? '' : urlp.pathname) +
urlp.search +
urlp.hash
if (shortened.length > 30) {
return shortened.slice(0, 27) + '...'
}
return shortened
} catch (e) {
return url
}
}
export function toShareUrl(url: string): string {
if (!url.startsWith('https')) {
const urlp = new URL('https://bsky.app')
urlp.pathname = url
url = urlp.toString()
}
return url
}
export function isBskyAppUrl(url: string): boolean {
return url.startsWith('https://bsky.app/')
}
export function convertBskyAppUrlIfNeeded(url: string): string {
if (isBskyAppUrl(url)) {
try {
const urlp = new URL(url)
return urlp.pathname
} catch (e) {
console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
}
}
return url
}

View File

@ -0,0 +1,23 @@
export function cleanError(str: any): string {
if (!str) {
return ''
}
if (typeof str !== 'string') {
str = str.toString()
}
if (isNetworkError(str)) {
return 'Unable to connect. Please check your internet connection and try again.'
}
if (str.includes('Upstream Failure')) {
return 'The server appears to be experiencing issues. Please try again in a few moments.'
}
if (str.startsWith('Error: ')) {
return str.slice('Error: '.length)
}
return str
}
export function isNetworkError(e: unknown) {
const str = String(e)
return str.includes('Abort') || str.includes('Network request failed')
}

View File

@ -0,0 +1,13 @@
export function makeValidHandle(str: string): string {
if (str.length > 20) {
str = str.slice(0, 20)
}
str = str.toLowerCase()
return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
}
export function createFullHandle(name: string, domain: string): string {
name = (name || '').replace(/[.]+$/, '')
domain = (domain || '').replace(/^[.]+/, '')
return `${name}.${domain}`
}

View File

@ -0,0 +1,17 @@
export function pluralize(n: number, base: string, plural?: string): string {
if (n === 1) {
return base
}
if (plural) {
return plural
}
return base + 's'
}
export function enforceLen(str: string, len: number, ellipsis = false): string {
str = str || ''
if (str.length > len) {
return str.slice(0, len) + (ellipsis ? '...' : '')
}
return str
}

View File

@ -0,0 +1,37 @@
interface FoundMention {
value: string
index: number
}
export function getMentionAt(
text: string,
cursorPos: number,
): FoundMention | undefined {
let re = /(^|\s)@([a-z0-9.]*)/gi
let match
while ((match = re.exec(text))) {
const spaceOffset = match[1].length
const index = match.index + spaceOffset
if (
cursorPos >= index &&
cursorPos <= index + match[0].length - spaceOffset
) {
return {value: match[2], index}
}
}
return undefined
}
export function insertMentionAt(
text: string,
cursorPos: number,
mention: string,
) {
const target = getMentionAt(text, cursorPos)
if (target) {
return `${text.slice(0, target.index)}@${mention} ${text.slice(
target.index + target.value.length + 1, // add 1 to include the "@"
)}`
}
return text
}

View File

@ -0,0 +1,107 @@
import {AppBskyFeedPost} from '@atproto/api'
type Entity = AppBskyFeedPost.Entity
import {isValidDomain} from './url-helpers'
export function extractEntities(
text: string,
knownHandles?: Set<string>,
): Entity[] | undefined {
let match
let ents: Entity[] = []
{
// mentions
const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g
while ((match = re.exec(text))) {
if (knownHandles && !knownHandles.has(match[3])) {
continue // not a known handle
} else if (!match[3].includes('.')) {
continue // probably not a handle
}
const start = text.indexOf(match[3], match.index) - 1
ents.push({
type: 'mention',
value: match[3],
index: {start, end: start + match[3].length + 1},
})
}
}
{
// links
const re =
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
while ((match = re.exec(text))) {
let value = match[2]
if (!value.startsWith('http')) {
const domain = match.groups?.domain
if (!domain || !isValidDomain(domain)) {
continue
}
value = `https://${value}`
}
const start = text.indexOf(match[2], match.index)
const index = {start, end: start + match[2].length}
// strip ending puncuation
if (/[.,;!?]$/.test(value)) {
value = value.slice(0, -1)
index.end--
}
if (/[)]$/.test(value) && !value.includes('(')) {
value = value.slice(0, -1)
index.end--
}
ents.push({
type: 'link',
value,
index,
})
}
}
return ents.length > 0 ? ents : undefined
}
interface DetectedLink {
link: string
}
type DetectedLinkable = string | DetectedLink
export function detectLinkables(text: string): DetectedLinkable[] {
const re =
/((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi
const segments = []
let match
let start = 0
while ((match = re.exec(text))) {
let matchIndex = match.index
let matchValue = match[0]
if (match.groups?.domain && !isValidDomain(match.groups?.domain)) {
continue
}
if (/\s|\(/.test(matchValue)) {
// HACK
// skip the starting space
// we have to do this because RN doesnt support negative lookaheads
// -prf
matchIndex++
matchValue = matchValue.slice(1)
}
// strip ending puncuation
if (/[.,;!?]$/.test(matchValue)) {
matchValue = matchValue.slice(0, -1)
}
if (/[)]$/.test(matchValue) && !matchValue.includes('(')) {
matchValue = matchValue.slice(0, -1)
}
if (start !== matchIndex) {
segments.push(text.slice(start, matchIndex))
}
segments.push({link: matchValue})
start = matchIndex + matchValue.length
}
if (start < text.length) {
segments.push(text.slice(start))
}
return segments
}

View File

@ -0,0 +1,32 @@
import {RichText} from './rich-text'
const EXCESS_SPACE_RE = /[\r\n]([\u00AD\u2060\u200D\u200C\u200B\s]*[\r\n]){2,}/
const REPLACEMENT_STR = '\n\n'
export function removeExcessNewlines(richText: RichText): RichText {
return clean(richText, EXCESS_SPACE_RE, REPLACEMENT_STR)
}
// TODO: check on whether this works correctly with multi-byte codepoints
export function clean(
richText: RichText,
targetRegexp: RegExp,
replacementString: string,
): RichText {
richText = richText.clone()
let match = richText.text.match(targetRegexp)
while (match && typeof match.index !== 'undefined') {
const oldText = richText.text
const removeStartIndex = match.index
const removeEndIndex = removeStartIndex + match[0].length
richText.delete(removeStartIndex, removeEndIndex)
if (richText.text === oldText) {
break // sanity check
}
richText.insert(removeStartIndex, replacementString)
match = richText.text.match(targetRegexp)
}
return richText
}

View File

@ -0,0 +1,216 @@
/*
= Rich Text Manipulation
When we sanitize rich text, we have to update the entity indices as the
text is modified. This can be modeled as inserts() and deletes() of the
rich text string. The possible scenarios are outlined below, along with
their expected behaviors.
NOTE: Slices are start inclusive, end exclusive
== richTextInsert()
Target string:
0 1 2 3 4 5 6 7 8 910 // string indices
h e l l o w o r l d // string value
^-------^ // target slice {start: 2, end: 7}
Scenarios:
A: ^ // insert "test" at 0
B: ^ // insert "test" at 4
C: ^ // insert "test" at 8
A = before -> move both by num added
B = inner -> move end by num added
C = after -> noop
Results:
A: 0 1 2 3 4 5 6 7 8 910 // string indices
t e s t h e l l o w // string value
^-------^ // target slice {start: 6, end: 11}
B: 0 1 2 3 4 5 6 7 8 910 // string indices
h e l l t e s t o w // string value
^---------------^ // target slice {start: 2, end: 11}
C: 0 1 2 3 4 5 6 7 8 910 // string indices
h e l l o w o t e s // string value
^-------^ // target slice {start: 2, end: 7}
== richTextDelete()
Target string:
0 1 2 3 4 5 6 7 8 910 // string indices
h e l l o w o r l d // string value
^-------^ // target slice {start: 2, end: 7}
Scenarios:
A: ^---------------^ // remove slice {start: 0, end: 9}
B: ^-----^ // remove slice {start: 7, end: 11}
C: ^-----------^ // remove slice {start: 4, end: 11}
D: ^-^ // remove slice {start: 3, end: 5}
E: ^-----^ // remove slice {start: 1, end: 5}
F: ^-^ // remove slice {start: 0, end: 2}
A = entirely outer -> delete slice
B = entirely after -> noop
C = partially after -> move end to remove-start
D = entirely inner -> move end by num removed
E = partially before -> move start to remove-start index, move end by num removed
F = entirely before -> move both by num removed
Results:
A: 0 1 2 3 4 5 6 7 8 910 // string indices
l d // string value
// target slice (deleted)
B: 0 1 2 3 4 5 6 7 8 910 // string indices
h e l l o w // string value
^-------^ // target slice {start: 2, end: 7}
C: 0 1 2 3 4 5 6 7 8 910 // string indices
h e l l // string value
^-^ // target slice {start: 2, end: 4}
D: 0 1 2 3 4 5 6 7 8 910 // string indices
h e l w o r l d // string value
^---^ // target slice {start: 2, end: 5}
E: 0 1 2 3 4 5 6 7 8 910 // string indices
h w o r l d // string value
^-^ // target slice {start: 1, end: 3}
F: 0 1 2 3 4 5 6 7 8 910 // string indices
l l o w o r l d // string value
^-------^ // target slice {start: 0, end: 5}
*/
import cloneDeep from 'lodash.clonedeep'
import {AppBskyFeedPost} from '@atproto/api'
import {removeExcessNewlines} from './rich-text-sanitize'
export type Entity = AppBskyFeedPost.Entity
export interface RichTextOpts {
cleanNewlines?: boolean
}
export class RichText {
constructor(
public text: string,
public entities?: Entity[],
opts?: RichTextOpts,
) {
if (opts?.cleanNewlines) {
removeExcessNewlines(this).copyInto(this)
}
}
clone() {
return new RichText(this.text, cloneDeep(this.entities))
}
copyInto(target: RichText) {
target.text = this.text
target.entities = cloneDeep(this.entities)
}
insert(insertIndex: number, insertText: string) {
this.text =
this.text.slice(0, insertIndex) +
insertText +
this.text.slice(insertIndex)
if (!this.entities?.length) {
return this
}
const numCharsAdded = insertText.length
for (const ent of this.entities) {
// see comment at top of file for labels of each scenario
// scenario A (before)
if (insertIndex <= ent.index.start) {
// move both by num added
ent.index.start += numCharsAdded
ent.index.end += numCharsAdded
}
// scenario B (inner)
else if (insertIndex >= ent.index.start && insertIndex < ent.index.end) {
// move end by num added
ent.index.end += numCharsAdded
}
// scenario C (after)
// noop
}
return this
}
delete(removeStartIndex: number, removeEndIndex: number) {
this.text =
this.text.slice(0, removeStartIndex) + this.text.slice(removeEndIndex)
if (!this.entities?.length) {
return this
}
const numCharsRemoved = removeEndIndex - removeStartIndex
for (const ent of this.entities) {
// see comment at top of file for labels of each scenario
// scenario A (entirely outer)
if (
removeStartIndex <= ent.index.start &&
removeEndIndex >= ent.index.end
) {
// delete slice (will get removed in final pass)
ent.index.start = 0
ent.index.end = 0
}
// scenario B (entirely after)
else if (removeStartIndex > ent.index.end) {
// noop
}
// scenario C (partially after)
else if (
removeStartIndex > ent.index.start &&
removeStartIndex <= ent.index.end &&
removeEndIndex > ent.index.end
) {
// move end to remove start
ent.index.end = removeStartIndex
}
// scenario D (entirely inner)
else if (
removeStartIndex >= ent.index.start &&
removeEndIndex <= ent.index.end
) {
// move end by num removed
ent.index.end -= numCharsRemoved
}
// scenario E (partially before)
else if (
removeStartIndex < ent.index.start &&
removeEndIndex >= ent.index.start &&
removeEndIndex <= ent.index.end
) {
// move start to remove-start index, move end by num removed
ent.index.start = removeStartIndex
ent.index.end -= numCharsRemoved
}
// scenario F (entirely before)
else if (removeEndIndex < ent.index.start) {
// move both by num removed
ent.index.start -= numCharsRemoved
ent.index.end -= numCharsRemoved
}
}
// filter out any entities that were made irrelevant
this.entities = this.entities.filter(ent => ent.index.start < ent.index.end)
return this
}
}

View File

@ -0,0 +1,29 @@
const MINUTE = 60
const HOUR = MINUTE * 60
const DAY = HOUR * 24
const MONTH = DAY * 30
const YEAR = DAY * 365
export function ago(date: number | string | Date): string {
let ts: number
if (typeof date === 'string') {
ts = Number(new Date(date))
} else if (date instanceof Date) {
ts = Number(date)
} else {
ts = date
}
const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
if (diffSeconds < MINUTE) {
return `${diffSeconds}s`
} else if (diffSeconds < HOUR) {
return `${Math.floor(diffSeconds / MINUTE)}m`
} else if (diffSeconds < DAY) {
return `${Math.floor(diffSeconds / HOUR)}h`
} else if (diffSeconds < MONTH) {
return `${Math.floor(diffSeconds / DAY)}d`
} else if (diffSeconds < YEAR) {
return `${Math.floor(diffSeconds / MONTH)}mo`
} else {
return new Date(ts).toLocaleDateString()
}
}

View File

@ -0,0 +1,108 @@
import {AtUri} from '../../third-party/uri'
import {PROD_SERVICE} from 'state/index'
import TLDs from 'tlds'
export function isValidDomain(str: string): boolean {
return !!TLDs.find(tld => {
let i = str.lastIndexOf(tld)
if (i === -1) {
return false
}
return str.charAt(i - 1) === '.' && i === str.length - tld.length
})
}
export function makeRecordUri(
didOrName: string,
collection: string,
rkey: string,
) {
const urip = new AtUri('at://host/')
urip.host = didOrName
urip.collection = collection
urip.rkey = rkey
return urip.toString()
}
export function toNiceDomain(url: string): string {
try {
const urlp = new URL(url)
if (`https://${urlp.host}` === PROD_SERVICE) {
return 'Bluesky Social'
}
return urlp.host
} catch (e) {
return url
}
}
export function toShortUrl(url: string): string {
try {
const urlp = new URL(url)
const shortened =
urlp.host +
(urlp.pathname === '/' ? '' : urlp.pathname) +
urlp.search +
urlp.hash
if (shortened.length > 30) {
return shortened.slice(0, 27) + '...'
}
return shortened
} catch (e) {
return url
}
}
export function toShareUrl(url: string): string {
if (!url.startsWith('https')) {
const urlp = new URL('https://bsky.app')
urlp.pathname = url
url = urlp.toString()
}
return url
}
export function isBskyAppUrl(url: string): boolean {
return url.startsWith('https://bsky.app/')
}
export function convertBskyAppUrlIfNeeded(url: string): string {
if (isBskyAppUrl(url)) {
try {
const urlp = new URL(url)
return urlp.pathname
} catch (e) {
console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
}
}
return url
}
export function getYoutubeVideoId(link: string): string | undefined {
let url
try {
url = new URL(link)
} catch (e) {
return undefined
}
if (
url.hostname !== 'www.youtube.com' &&
url.hostname !== 'youtube.com' &&
url.hostname !== 'youtu.be'
) {
return undefined
}
if (url.hostname === 'youtu.be') {
const videoId = url.pathname.split('/')[1]
if (!videoId) {
return undefined
}
return videoId
}
const videoId = url.searchParams.get('v') as string
if (!videoId) {
return undefined
}
return videoId
}

View File

@ -1,4 +1,4 @@
import {StyleSheet, TextStyle} from 'react-native'
import {StyleProp, StyleSheet, TextStyle} from 'react-native'
import {Theme, TypographyVariant} from './ThemeContext'
// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
@ -206,3 +206,13 @@ export function lh(
lineHeight: (theme.typography[type].fontSize || 16) * height,
}
}
export function addStyle<T>(
base: StyleProp<T>,
addedStyle: StyleProp<T>,
): StyleProp<T> {
if (Array.isArray(base)) {
return base.concat([addedStyle])
}
return [base, addedStyle]
}

View File

@ -14,7 +14,7 @@ export const defaultTheme: Theme = {
link: colors.blue3,
border: '#f0e9e9',
borderDark: '#e0d9d9',
icon: colors.gray3,
icon: colors.gray4,
// non-standard
textVeryLight: colors.gray4,
@ -208,11 +208,16 @@ export const defaultTheme: Theme = {
fontWeight: '800',
},
'title-xl': {
'title-2xl': {
fontSize: 34,
letterSpacing: 0.25,
fontWeight: '500',
},
'title-xl': {
fontSize: 28,
letterSpacing: 0.25,
fontWeight: '500',
},
'title-lg': {
fontSize: 22,
fontWeight: '500',
@ -237,6 +242,11 @@ export const defaultTheme: Theme = {
letterSpacing: 0.4,
fontWeight: '400',
},
'button-lg': {
fontWeight: '500',
fontSize: 18,
letterSpacing: 0.5,
},
button: {
fontWeight: '500',
fontSize: 14,
@ -263,7 +273,7 @@ export const darkTheme: Theme = {
link: colors.blue3,
border: colors.gray6,
borderDark: colors.gray5,
icon: colors.gray5,
icon: colors.gray4,
// non-standard
textVeryLight: colors.gray4,

View File

@ -1,9 +1,9 @@
import {autorun} from 'mobx'
import {Platform} from 'react-native'
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
import {AppState, Platform} from 'react-native'
import {AtpAgent} from '@atproto/api'
import {RootStoreModel} from './models/root-store'
import * as apiPolyfill from './lib/api-polyfill'
import * as storage from './lib/storage'
import * as apiPolyfill from 'lib/api/api-polyfill'
import * as storage from 'lib/storage'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'ios' ? 'http://localhost:2583' : 'http://10.0.2.2:2583'
@ -19,8 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
apiPolyfill.doPolyfill()
const api = AtpApi.service(serviceUri) as SessionServiceClient
rootStore = new RootStoreModel(api)
rootStore = new RootStoreModel(new AtpAgent({service: serviceUri}))
try {
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
@ -28,25 +27,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
} catch (e: any) {
rootStore.log.error('Failed to load state from storage', e)
}
rootStore.session
.connect()
.then(() => {
rootStore.log.debug('Session connected')
return rootStore.fetchStateUpdate()
})
.catch((e: any) => {
rootStore.log.warn('Failed initial connect', e)
})
// @ts-ignore .on() is correct -prf
api.sessionManager.on('session', () => {
if (!api.sessionManager.session && rootStore.session.hasSession) {
// reset session
rootStore.session.clear()
} else if (api.sessionManager.session) {
rootStore.session.updateAuthTokens(api.sessionManager.session)
}
})
rootStore.attemptSessionResumption()
// track changes & save to storage
autorun(() => {
@ -56,7 +37,14 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
// periodic state fetch
setInterval(() => {
rootStore.fetchStateUpdate()
// NOTE
// this must ONLY occur when the app is active, as the bg-fetch handler
// will wake up the thread and cause this interval to fire, which in
// turn schedules a bunch of work at a poor time
// -prf
if (AppState.currentState === 'active') {
rootStore.updateSessionState()
}
}, STATE_FETCH_INTERVAL)
return rootStore

View File

@ -5,13 +5,16 @@ import {
AppBskyFeedPost,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
} from '@atproto/api'
import AwaitLock from 'await-lock'
import {bundleAsync} from 'lib/async/bundle'
type FeedViewPost = AppBskyFeedFeedViewPost.Main
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
type PostView = AppBskyFeedPost.View
import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api'
import {cleanError} from '../../lib/strings'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {RichText} from 'lib/strings/rich-text'
const PAGE_SIZE = 30
@ -37,6 +40,7 @@ export class FeedItemModel {
reply?: FeedViewPost['reply']
replyParent?: FeedItemModel
reason?: FeedViewPost['reason']
richText?: RichText
constructor(
public rootStore: RootStoreModel,
@ -49,6 +53,11 @@ export class FeedItemModel {
const valid = AppBskyFeedPost.validateRecord(this.post.record)
if (valid.success) {
this.postRecord = this.post.record
this.richText = new RichText(
this.postRecord.text,
this.postRecord.entities,
{cleanNewlines: true},
)
} else {
rootStore.log.warn(
'Received an invalid app.bsky.feed.post record',
@ -187,10 +196,9 @@ export class FeedModel {
hasMore = true
loadMoreCursor: string | undefined
pollCursor: string | undefined
_loadPromise: Promise<void> | undefined
_loadMorePromise: Promise<void> | undefined
_loadLatestPromise: Promise<void> | undefined
_updatePromise: Promise<void> | undefined
// used to linearize async modifications to state
private lock = new AwaitLock()
// data
feed: FeedItemModel[] = []
@ -206,10 +214,6 @@ export class FeedModel {
rootStore: false,
params: false,
loadMoreCursor: false,
_loadPromise: false,
_loadMorePromise: false,
_loadLatestPromise: false,
_updatePromise: false,
},
{autoBind: true},
)
@ -229,13 +233,22 @@ export class FeedModel {
}
get nonReplyFeed() {
return this.feed.filter(
item =>
const nonReplyFeed = this.feed.filter(item => {
const params = this.params as GetAuthorFeed.QueryParams
const isRepost =
item.reply &&
(item?.reasonRepost?.by?.handle === params.author ||
item?.reasonRepost?.by?.did === params.author)
return (
!item.reply || // not a reply
isRepost ||
((item._isThreadParent || // but allow if it's a thread by the user
item._isThreadChild) &&
item.reply?.root.author.did === item.post.author.did),
item.reply?.root.author.did === item.post.author.did)
)
})
return nonReplyFeed
}
setHasNewLatest(v: boolean) {
@ -245,22 +258,45 @@ export class FeedModel {
// public api
// =
/**
* Nuke all data
*/
clear() {
this.rootStore.log.debug('FeedModel:clear')
this.isLoading = false
this.isRefreshing = false
this.hasNewLatest = false
this.hasLoaded = false
this.error = ''
this.hasMore = true
this.loadMoreCursor = undefined
this.pollCursor = undefined
this.feed = []
}
/**
* Load for first render
*/
async setup(isRefreshing = false) {
setup = bundleAsync(async (isRefreshing: boolean = false) => {
this.rootStore.log.debug('FeedModel:setup', {isRefreshing})
if (isRefreshing) {
this.isRefreshing = true // set optimistically for UI
}
if (this._loadPromise) {
return this._loadPromise
}
await this._pendingWork()
await this.lock.acquireAsync()
try {
this.setHasNewLatest(false)
this._loadPromise = this._initialLoad(isRefreshing)
await this._loadPromise
this._loadPromise = undefined
this._xLoading(isRefreshing)
try {
const res = await this._getFeed({limit: PAGE_SIZE})
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
} finally {
this.lock.release()
}
})
/**
* Register any event listeners. Returns a cleanup function.
@ -280,42 +316,93 @@ export class FeedModel {
/**
* Load more posts to the end of the feed
*/
async loadMore() {
if (this._loadMorePromise) {
return this._loadMorePromise
loadMore = bundleAsync(async () => {
await this.lock.acquireAsync()
try {
if (!this.hasMore || this.hasError) {
return
}
await this._pendingWork()
this._loadMorePromise = this._loadMore()
await this._loadMorePromise
this._loadMorePromise = undefined
this._xLoading()
try {
const res = await this._getFeed({
before: this.loadMoreCursor,
limit: PAGE_SIZE,
})
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle() // don't bubble the error to the user
this.rootStore.log.error('FeedView: Failed to load more', {
params: this.params,
e,
})
}
} finally {
this.lock.release()
}
})
/**
* Load more posts to the start of the feed
*/
async loadLatest() {
if (this._loadLatestPromise) {
return this._loadLatestPromise
}
await this._pendingWork()
loadLatest = bundleAsync(async () => {
await this.lock.acquireAsync()
try {
this.setHasNewLatest(false)
this._loadLatestPromise = this._loadLatest()
await this._loadLatestPromise
this._loadLatestPromise = undefined
this._xLoading()
try {
const res = await this._getFeed({limit: PAGE_SIZE})
await this._prependAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle() // don't bubble the error to the user
this.rootStore.log.error('FeedView: Failed to load latest', {
params: this.params,
e,
})
}
} finally {
this.lock.release()
}
})
/**
* Update content in-place
*/
async update() {
if (this._updatePromise) {
return this._updatePromise
update = bundleAsync(async () => {
await this.lock.acquireAsync()
try {
if (!this.feed.length) {
return
}
await this._pendingWork()
this._updatePromise = this._update()
await this._updatePromise
this._updatePromise = undefined
this._xLoading()
let numToFetch = this.feed.length
let cursor
try {
do {
const res: GetTimeline.Response = await this._getFeed({
before: cursor,
limit: Math.min(numToFetch, 100),
})
if (res.data.feed.length === 0) {
break // sanity check
}
this._updateAll(res)
numToFetch -= res.data.feed.length
cursor = res.data.cursor
} while (cursor && numToFetch > 0)
this._xIdle()
} catch (e: any) {
this._xIdle() // don't bubble the error to the user
this.rootStore.log.error('FeedView: Failed to update', {
params: this.params,
e,
})
}
} finally {
this.lock.release()
}
})
/**
* Check if new posts are available
@ -324,17 +411,18 @@ export class FeedModel {
if (this.hasNewLatest) {
return
}
await this._pendingWork()
const res = await this._getFeed({limit: 1})
const currentLatestUri = this.pollCursor
const receivedLatestUri = res.data.feed[0]
? res.data.feed[0].post.uri
: undefined
const hasNewLatest = Boolean(
receivedLatestUri &&
(this.feed.length === 0 || receivedLatestUri !== currentLatestUri),
)
this.setHasNewLatest(hasNewLatest)
const item = res.data.feed[0]
if (!item) {
return
}
if (AppBskyFeedFeedViewPost.isReasonRepost(item.reason)) {
if (item.reason.by.did === this.rootStore.me.did) {
return // ignore reposts by the user
}
}
this.setHasNewLatest(item.post.uri !== currentLatestUri)
}
/**
@ -363,95 +451,15 @@ export class FeedModel {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err ? cleanError(err.toString()) : ''
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Posts feed request failed', err)
}
}
// loader functions
// helper functions
// =
private async _pendingWork() {
if (this._loadPromise) {
await this._loadPromise
}
if (this._loadMorePromise) {
await this._loadMorePromise
}
if (this._loadLatestPromise) {
await this._loadLatestPromise
}
if (this._updatePromise) {
await this._updatePromise
}
}
private async _initialLoad(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const res = await this._getFeed({limit: PAGE_SIZE})
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
private async _loadLatest() {
this._xLoading()
try {
const res = await this._getFeed({limit: PAGE_SIZE})
await this._prependAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
private async _loadMore() {
if (!this.hasMore || this.hasError) {
return
}
this._xLoading()
try {
const res = await this._getFeed({
before: this.loadMoreCursor,
limit: PAGE_SIZE,
})
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
private async _update() {
if (!this.feed.length) {
return
}
this._xLoading()
let numToFetch = this.feed.length
let cursor
try {
do {
const res: GetTimeline.Response = await this._getFeed({
before: cursor,
limit: Math.min(numToFetch, 100),
})
if (res.data.feed.length === 0) {
break // sanity check
}
this._updateAll(res)
numToFetch -= res.data.feed.length
cursor = res.data.cursor
} while (cursor && numToFetch > 0)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
private async _replaceAll(
res: GetTimeline.Response | GetAuthorFeed.Response,
) {
@ -570,11 +578,46 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
reorg.unshift(item)
}
// phase two: identify the positions of the threads
// phase two: reorder the feed so that the timestamp of the
// last post in a thread establishes its ordering
let threadSlices: Slice[] = identifyThreadSlices(reorg)
for (const slice of threadSlices) {
const removed: FeedViewPostWithThreadMeta[] = reorg.splice(
slice.index,
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 three: compress any threads that are longer than 3 posts
let removedCount = 0
// phase 2 moved posts around, so we need to re-identify the slice indices
threadSlices = identifyThreadSlices(reorg)
for (const slice of threadSlices) {
if (slice.length > 3) {
reorg.splice(slice.index - removedCount + 1, slice.length - 3)
if (reorg[slice.index - removedCount]) {
// ^ sanity check
reorg[slice.index - removedCount]._isThreadChildElided = true
}
removedCount += slice.length - 3
}
}
return reorg
}
function identifyThreadSlices(feed: FeedViewPost[]): Slice[] {
let activeSlice = -1
let threadSlices: Slice[] = []
for (let i = 0; i < reorg.length; i++) {
const item = reorg[i] as FeedViewPostWithThreadMeta
for (let i = 0; i < feed.length; i++) {
const item = feed[i] as FeedViewPostWithThreadMeta
if (activeSlice === -1) {
if (item._isThreadParent) {
activeSlice = i
@ -591,39 +634,9 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
}
}
if (activeSlice !== -1) {
threadSlices.push({index: activeSlice, length: reorg.length - activeSlice})
threadSlices.push({index: activeSlice, length: feed.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)
if (reorg[slice.index - removedCount]) {
// ^ sanity check
reorg[slice.index - removedCount]._isThreadChildElided = true
}
removedCount += slice.length - 3
}
}
return reorg
return threadSlices
}
// WARNING: mutates `feed`

View File

@ -1,123 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {AppBskyGraphGetAssertions as GetAssertions} from '@atproto/api'
import {RootStoreModel} from './root-store'
export type Assertion = GetAssertions.Assertion & {
_reactKey: string
}
export class GetAssertionsView {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: GetAssertions.QueryParams
// data
assertions: Assertion[] = []
constructor(
public rootStore: RootStoreModel,
params: GetAssertions.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.assertions.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
getBySubject(did: string) {
return this.assertions.find(assertion => assertion.subject.did === did)
}
get confirmed() {
return this.assertions.filter(assertion => !!assertion.confirmation)
}
get unconfirmed() {
return this.assertions.filter(assertion => !assertion.confirmation)
}
// public api
// =
async setup() {
await this._fetch()
}
async refresh() {
await this._fetch(true)
}
async loadMore() {
// TODO
}
// state transitions
// =
private _xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
private _xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err ? err.toString() : ''
if (err) {
this.rootStore.log.error('Failed to fetch assertions', err)
}
}
// loader functions
// =
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const res = await this.rootStore.api.app.bsky.graph.getAssertions(
this.params,
)
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
private _replaceAll(res: GetAssertions.Response) {
this.assertions.length = 0
let counter = 0
for (const item of res.data.assertions) {
this._append({
_reactKey: `item-${counter++}`,
...item,
})
}
}
private _append(item: Assertion) {
this.assertions.push(item)
}
}

View File

@ -1,7 +1,7 @@
import {makeAutoObservable} from 'mobx'
import {LRUMap} from 'lru_map'
import {RootStoreModel} from './root-store'
import {LinkMeta, getLinkMeta} from '../../lib/link-meta'
import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta'
type CacheValue = Promise<LinkMeta> | LinkMeta
export class LinkMetasViewModel {

View File

@ -1,6 +1,6 @@
import {makeAutoObservable} from 'mobx'
import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc'
import {isObj, hasProp} from '../lib/type-guards'
import {isObj, hasProp} from 'lib/type-guards'
interface LogEntry {
id: string

View File

@ -1,10 +1,9 @@
import {makeAutoObservable, runInAction} from 'mobx'
import notifee from '@notifee/react-native'
import {RootStoreModel} from './root-store'
import {FeedModel} from './feed-view'
import {NotificationsViewModel} from './notifications-view'
import {isObj, hasProp} from '../lib/type-guards'
import {displayNotificationFromModel} from '../../view/lib/notifee'
import {MyFollowsModel} from './my-follows'
import {isObj, hasProp} from 'lib/type-guards'
export class MeModel {
did: string = ''
@ -12,9 +11,9 @@ export class MeModel {
displayName: string = ''
description: string = ''
avatar: string = ''
notificationCount: number = 0
mainFeed: FeedModel
notifications: NotificationsViewModel
follows: MyFollowsModel
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
@ -26,15 +25,17 @@ export class MeModel {
algorithm: 'reverse-chronological',
})
this.notifications = new NotificationsViewModel(this.rootStore, {})
this.follows = new MyFollowsModel(this.rootStore)
}
clear() {
this.mainFeed.clear()
this.notifications.clear()
this.did = ''
this.handle = ''
this.displayName = ''
this.description = ''
this.avatar = ''
this.notificationCount = 0
}
serialize(): unknown {
@ -77,9 +78,10 @@ export class MeModel {
async load() {
const sess = this.rootStore.session
if (sess.hasSession && sess.data) {
this.did = sess.data.did || ''
this.handle = sess.data.handle
this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession})
if (sess.hasSession) {
this.did = sess.currentSession?.did || ''
this.handle = sess.currentSession?.handle || ''
const profile = await this.rootStore.api.app.bsky.actor.getProfile({
actor: this.did,
})
@ -94,10 +96,6 @@ export class MeModel {
this.avatar = ''
}
})
this.mainFeed = new FeedModel(this.rootStore, 'home', {
algorithm: 'reverse-chronological',
})
this.notifications = new NotificationsViewModel(this.rootStore, {})
await Promise.all([
this.mainFeed.setup().catch(e => {
this.rootStore.log.error('Failed to setup main feed model', e)
@ -105,51 +103,13 @@ export class MeModel {
this.notifications.setup().catch(e => {
this.rootStore.log.error('Failed to setup notifications model', e)
}),
this.follows.fetch().catch(e => {
this.rootStore.log.error('Failed to load my follows', e)
}),
])
// request notifications permission once the user has logged in
notifee.requestPermission()
this.rootStore.emitSessionLoaded()
} else {
this.clear()
}
}
clearNotificationCount() {
this.notificationCount = 0
notifee.setBadgeCount(0)
}
async fetchNotifications() {
const res = await this.rootStore.api.app.bsky.notification.getCount()
runInAction(() => {
const newNotifications = this.notificationCount !== res.data.count
this.notificationCount = res.data.count
notifee.setBadgeCount(this.notificationCount)
if (newNotifications) {
this.notifications.refresh()
}
})
}
async bgFetchNotifications() {
const res = await this.rootStore.api.app.bsky.notification.getCount()
// NOTE we don't update this.notificationCount to avoid repaints during bg
// this means `newNotifications` may not be accurate, so we rely on
// `mostRecent` to determine if there really is a new notif to show -prf
const newNotifications = this.notificationCount !== res.data.count
notifee.setBadgeCount(res.data.count)
this.rootStore.log.debug(
`Background fetch received unread count = ${res.data.count}`,
)
if (newNotifications) {
this.rootStore.log.debug(
'Background fetch detected potentially a new notification',
)
const mostRecent = await this.notifications.getNewMostRecent()
if (mostRecent) {
this.rootStore.log.debug('Got the notification, triggering a push')
displayNotificationFromModel(mostRecent)
}
}
}
}

View File

@ -0,0 +1,109 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {bundleAsync} from 'lib/async/bundle'
const CACHE_TTL = 1000 * 60 * 60 // hourly
type FollowsListResponse = Awaited<ReturnType<FollowRecord['list']>>
type FollowsListResponseRecord = FollowsListResponse['records'][0]
type Profile =
| AppBskyActorProfile.ViewBasic
| AppBskyActorProfile.View
| AppBskyActorRef.WithInfo
/**
* This model is used to maintain a synced local cache of the user's
* follows. It should be periodically refreshed and updated any time
* the user makes a change to their follows.
*/
export class MyFollowsModel {
// data
followDidToRecordMap: Record<string, string> = {}
lastSync = 0
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
// public api
// =
fetchIfNeeded = bundleAsync(async () => {
if (
Object.keys(this.followDidToRecordMap).length === 0 ||
Date.now() - this.lastSync > CACHE_TTL
) {
return await this.fetch()
}
})
fetch = bundleAsync(async () => {
this.rootStore.log.debug('MyFollowsModel:fetch running full fetch')
let before
let records: FollowsListResponseRecord[] = []
do {
const res: FollowsListResponse =
await this.rootStore.api.app.bsky.graph.follow.list({
user: this.rootStore.me.did,
before,
})
records = records.concat(res.records)
before = res.cursor
} while (typeof before !== 'undefined')
runInAction(() => {
this.followDidToRecordMap = {}
for (const record of records) {
this.followDidToRecordMap[record.value.subject.did] = record.uri
}
this.lastSync = Date.now()
})
})
isFollowing(did: string) {
return !!this.followDidToRecordMap[did]
}
getFollowUri(did: string): string {
const v = this.followDidToRecordMap[did]
if (!v) {
throw new Error('Not a followed user')
}
return v
}
addFollow(did: string, recordUri: string) {
this.followDidToRecordMap[did] = recordUri
}
removeFollow(did: string) {
delete this.followDidToRecordMap[did]
}
/**
* Use this to incrementally update the cache as views provide information
*/
hydrate(did: string, recordUri: string | undefined) {
if (recordUri) {
this.followDidToRecordMap[did] = recordUri
} else {
delete this.followDidToRecordMap[did]
}
}
/**
* Use this to incrementally update the cache as views provide information
*/
hydrateProfiles(profiles: Profile[]) {
for (const profile of profiles) {
if (profile.viewer) {
this.hydrate(profile.did, profile.viewer.following)
}
}
}
}

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