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
parent
7916b26aad
commit
f28334739b
|
@ -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',
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -63,3 +63,4 @@ buck-out/
|
|||
# Testing
|
||||
coverage/
|
||||
junit.xml
|
||||
artifacts
|
27
README.md
27
README.md
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -1,4 +1,4 @@
|
|||
import {isNetworkError} from '../../src/lib/errors'
|
||||
import {isNetworkError} from '../../src/lib/strings/errors'
|
||||
|
||||
describe('isNetworkError', () => {
|
||||
const inputs = [
|
||||
|
|
|
@ -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'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',
|
||||
|
|
|
@ -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'},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
})
|
|
@ -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)
|
||||
}
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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}',
|
||||
)
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
// afterAll(() => {
|
||||
// jest.clearAllMocks()
|
||||
// })
|
||||
|
||||
it('should create new tabs', () => {
|
||||
// @ts-expect-error
|
||||
flags.TABS_ENABLED = true
|
||||
// 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)
|
||||
})
|
||||
// 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
|
||||
// it('should change the current tab', () => {
|
||||
// // @ts-expect-error
|
||||
// flags.TABS_ENABLED = true
|
||||
|
||||
model.setActiveTab(0)
|
||||
expect(model.tabIndex).toBe(0)
|
||||
})
|
||||
// model.setActiveTab(0)
|
||||
// expect(model.tabIndex).toBe(0)
|
||||
// })
|
||||
|
||||
it('should close tabs', () => {
|
||||
// @ts-expect-error
|
||||
flags.TABS_ENABLED = true
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
// 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)
|
||||
// })
|
||||
// })
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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'])
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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('/')
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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 */
|
||||
|
|
|
@ -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
|
||||
],
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
})
|
25
ios/Podfile
25
ios/Podfile
|
@ -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
|
||||
|
|
559
ios/Podfile.lock
559
ios/Podfile.lock
|
@ -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
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
[[TSBackgroundFetch sharedInstance] didFinishLaunching];
|
||||
|
||||
self.moduleName = @"xyz.blueskyweb.app";
|
||||
self.initialProps = @{};
|
||||
return [super application:application didFinishLaunchingWithOptions:launchOptions];
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'),
|
||||
)
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
|
45
package.json
45
package.json
|
@ -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/",
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 {
|
|
@ -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)
|
|
@ -0,0 +1,4 @@
|
|||
import VersionNumber from 'react-native-version-number'
|
||||
|
||||
export const appVersion = VersionNumber.appVersion
|
||||
export const buildVersion = VersionNumber.buildVersion
|
|
@ -0,0 +1,3 @@
|
|||
// TODO
|
||||
export const appVersion = 'TODO'
|
||||
export const buildVersion = 'TODO'
|
|
@ -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')
|
|
@ -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'}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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},
|
|
@ -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`,
|
||||
)
|
|
@ -1,4 +0,0 @@
|
|||
export function isNetworkError(e: unknown) {
|
||||
const str = String(e)
|
||||
return str.includes('Abort') || str.includes('Network request failed')
|
||||
}
|
|
@ -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>,
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -1,5 +1,5 @@
|
|||
import {extractTwitterMeta} from './extractTwitterMeta'
|
||||
import {extractYoutubeMeta} from './extractYoutubeMeta'
|
||||
import {extractTwitterMeta} from './twitter'
|
||||
import {extractYoutubeMeta} from './youtube'
|
||||
|
||||
interface ExtractHtmlMetaInput {
|
||||
html: string
|
|
@ -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,
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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')
|
||||
}
|
|
@ -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}`
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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]
|
||||
}
|
|
@ -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,
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue