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,
|
root: true,
|
||||||
extends: '@react-native-community',
|
extends: '@react-native-community',
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
plugins: ['@typescript-eslint'],
|
plugins: ['@typescript-eslint', 'detox'],
|
||||||
ignorePatterns: [
|
ignorePatterns: [
|
||||||
'**/__mocks__/*.ts',
|
'**/__mocks__/*.ts',
|
||||||
'src/third-party',
|
'src/third-party',
|
||||||
|
|
|
@ -11,7 +11,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
- name: Yarn install
|
- name: Yarn install
|
||||||
run: yarn
|
run: yarn
|
||||||
- name: Lint Reporter
|
- name: Lint Reporter
|
||||||
|
@ -30,7 +30,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
- name: Yarn install
|
- name: Yarn install
|
||||||
run: yarn
|
run: yarn
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
|
|
@ -62,4 +62,5 @@ buck-out/
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
junit.xml
|
junit.xml
|
||||||
|
artifacts
|
27
README.md
27
README.md
|
@ -1,18 +1,12 @@
|
||||||
# Social App
|
# Bluesky
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
## Build instructions
|
## Build instructions
|
||||||
|
|
||||||
- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
|
- 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:
|
- After initial setup:
|
||||||
- `cd ios ; pod install`
|
- `cd ios ; pod install`
|
||||||
- Start the dev servers
|
- Start the dev servers
|
||||||
|
@ -34,6 +28,17 @@ Uses:
|
||||||
|
|
||||||
## Various notes
|
## 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
|
### Polyfills
|
||||||
|
|
||||||
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes:
|
`./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', () => {
|
describe('isNetworkError', () => {
|
||||||
const inputs = [
|
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 {exampleComHtml} from './__mocks__/exampleComHtml'
|
||||||
import {youtubeHTML} from './__mocks__/youtubeHtml'
|
import {youtubeHTML} from './__mocks__/youtubeHtml'
|
||||||
import {tiktokHtml} from './__mocks__/tiktokHtml'
|
import {tiktokHtml} from './__mocks__/tiktokHtml'
|
||||||
|
import {youtubeChannelHtml} from './__mocks__/youtubeChannelHtml'
|
||||||
|
|
||||||
describe('extractHtmlMeta', () => {
|
describe('extractHtmlMeta', () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
|
@ -82,6 +83,19 @@ describe('extractHtmlMeta', () => {
|
||||||
expect(output).toEqual(expectedOutput)
|
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', () => {
|
it('extracts username from the url a twitter profile page', () => {
|
||||||
const expectedOutput = {
|
const expectedOutput = {
|
||||||
title: '@bluesky on Twitter',
|
title: '@bluesky on Twitter',
|
||||||
|
|
|
@ -78,8 +78,14 @@ describe('downloadAndResize', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return undefined for unsupported file type', async () => {
|
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 = {
|
const opts: DownloadAndResizeOpts = {
|
||||||
uri: 'https://example.com/image.bmp',
|
uri: 'https://example.com/image',
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
maxSize: 500000,
|
maxSize: 500000,
|
||||||
|
@ -88,6 +94,25 @@ describe('downloadAndResize', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await downloadAndResize(opts)
|
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 {exampleComHtml} from './__mocks__/exampleComHtml'
|
||||||
import {mockedRootStore} from '../../__mocks__/state-mock'
|
import AtpAgent from '@atproto/api'
|
||||||
|
import {DEFAULT_SERVICE, RootStoreModel} from '../../src/state'
|
||||||
|
|
||||||
describe('getLinkMeta', () => {
|
describe('getLinkMeta', () => {
|
||||||
|
let rootStore: RootStoreModel
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE}))
|
||||||
|
})
|
||||||
|
|
||||||
const inputs = [
|
const inputs = [
|
||||||
'',
|
'',
|
||||||
'httpbadurl',
|
'httpbadurl',
|
||||||
|
@ -88,7 +99,7 @@ describe('getLinkMeta', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const input = inputs[i]
|
const input = inputs[i]
|
||||||
const output = await getLinkMeta(mockedRootStore, input)
|
const output = await getLinkMeta(rootStore, input)
|
||||||
expect(output).toEqual(outputs[i])
|
expect(output).toEqual(outputs[i])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
import {
|
import {
|
||||||
extractEntities,
|
getYoutubeVideoId,
|
||||||
detectLinkables,
|
|
||||||
pluralize,
|
|
||||||
makeRecordUri,
|
makeRecordUri,
|
||||||
ago,
|
|
||||||
makeValidHandle,
|
|
||||||
createFullHandle,
|
|
||||||
enforceLen,
|
|
||||||
cleanError,
|
|
||||||
toNiceDomain,
|
toNiceDomain,
|
||||||
toShortUrl,
|
toShortUrl,
|
||||||
toShareUrl,
|
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', () => {
|
describe('extractEntities', () => {
|
||||||
const knownHandles = new Set(['handle.com', 'full123.test-of-chars'])
|
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 {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', () => {
|
describe('NavigationModel', () => {
|
||||||
let model: NavigationModel
|
let model: NavigationModel
|
||||||
|
let rootStore: RootStoreModel
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
model = new NavigationModel()
|
rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE}))
|
||||||
|
model = new NavigationModel(rootStore)
|
||||||
model.setTitle('0-0', 'title')
|
model.setTitle('0-0', 'title')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -15,7 +20,7 @@ describe('NavigationModel', () => {
|
||||||
|
|
||||||
it('should clear() to the correct base state', async () => {
|
it('should clear() to the correct base state', async () => {
|
||||||
await model.clear()
|
await model.clear()
|
||||||
expect(model.tabCount).toBe(2)
|
expect(model.tabCount).toBe(3)
|
||||||
expect(model.tab).toEqual({
|
expect(model.tab).toEqual({
|
||||||
fixedTabPurpose: 0,
|
fixedTabPurpose: 0,
|
||||||
history: [
|
history: [
|
||||||
|
@ -64,7 +69,7 @@ describe('NavigationModel', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call the tabCount getter', () => {
|
it('should call the tabCount getter', () => {
|
||||||
expect(model.tabCount).toBe(2)
|
expect(model.tabCount).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('tabs not enabled', () => {
|
describe('tabs not enabled', () => {
|
||||||
|
@ -87,7 +92,7 @@ describe('NavigationModel', () => {
|
||||||
it('should not change the active tab', () => {
|
it('should not change the active tab', () => {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
flags.TABS_ENABLED = false
|
flags.TABS_ENABLED = false
|
||||||
model.setActiveTab(2)
|
model.setActiveTab(3)
|
||||||
expect(model.tabIndex).toBe(0)
|
expect(model.tabIndex).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -95,57 +100,58 @@ describe('NavigationModel', () => {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
flags.TABS_ENABLED = false
|
flags.TABS_ENABLED = false
|
||||||
model.closeTab(0)
|
model.closeTab(0)
|
||||||
expect(model.tabCount).toBe(2)
|
expect(model.tabCount).toBe(3)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('tabs enabled', () => {
|
// TODO restore when tabs get re-enabled
|
||||||
jest.mock('../../../src/build-flags', () => ({
|
// describe('tabs enabled', () => {
|
||||||
TABS_ENABLED: true,
|
// jest.mock('../../../src/build-flags', () => ({
|
||||||
}))
|
// TABS_ENABLED: true,
|
||||||
|
// }))
|
||||||
|
|
||||||
afterAll(() => {
|
// afterAll(() => {
|
||||||
jest.clearAllMocks()
|
// jest.clearAllMocks()
|
||||||
})
|
// })
|
||||||
|
|
||||||
it('should create new tabs', () => {
|
// it('should create new tabs', () => {
|
||||||
// @ts-expect-error
|
// // @ts-expect-error
|
||||||
flags.TABS_ENABLED = true
|
// flags.TABS_ENABLED = true
|
||||||
|
|
||||||
model.newTab('testurl', 'title')
|
// model.newTab('testurl', 'title')
|
||||||
expect(model.tab.isNewTab).toBe(true)
|
// expect(model.tab.isNewTab).toBe(true)
|
||||||
expect(model.tabIndex).toBe(2)
|
// expect(model.tabIndex).toBe(2)
|
||||||
})
|
// })
|
||||||
|
|
||||||
it('should change the current tab', () => {
|
// it('should change the current tab', () => {
|
||||||
// @ts-expect-error
|
// // @ts-expect-error
|
||||||
flags.TABS_ENABLED = true
|
// flags.TABS_ENABLED = true
|
||||||
|
|
||||||
model.setActiveTab(0)
|
// model.setActiveTab(0)
|
||||||
expect(model.tabIndex).toBe(0)
|
// expect(model.tabIndex).toBe(0)
|
||||||
})
|
// })
|
||||||
|
|
||||||
it('should close tabs', () => {
|
// it('should close tabs', () => {
|
||||||
// @ts-expect-error
|
// // @ts-expect-error
|
||||||
flags.TABS_ENABLED = true
|
// flags.TABS_ENABLED = true
|
||||||
|
|
||||||
model.closeTab(0)
|
// model.closeTab(0)
|
||||||
expect(model.tabs).toEqual([
|
// expect(model.tabs).toEqual([
|
||||||
{
|
// {
|
||||||
fixedTabPurpose: 1,
|
// fixedTabPurpose: 1,
|
||||||
history: [
|
// history: [
|
||||||
{
|
// {
|
||||||
id: expect.anything(),
|
// id: expect.anything(),
|
||||||
ts: expect.anything(),
|
// ts: expect.anything(),
|
||||||
url: '/notifications',
|
// url: '/notifications',
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
id: expect.anything(),
|
// id: expect.anything(),
|
||||||
index: 0,
|
// index: 0,
|
||||||
isNewTab: false,
|
// isNewTab: false,
|
||||||
},
|
// },
|
||||||
])
|
// ])
|
||||||
expect(model.tabIndex).toBe(0)
|
// 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 '..'
|
// The root of your project, i.e. where "package.json" lives. Default is '..'
|
||||||
// root = file("../")
|
// root = file("../")
|
||||||
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
|
// 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
|
// 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
|
// 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")
|
// cliFile = file("../node_modules/react-native/cli.js")
|
||||||
/* Variants */
|
/* Variants */
|
||||||
|
|
|
@ -14,6 +14,17 @@ module.exports = {
|
||||||
verbose: false,
|
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
|
'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
|
platform :ios, min_ios_version_supported
|
||||||
prepare_react_native_project!
|
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
|
flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled
|
||||||
|
|
||||||
linkage = ENV['USE_FRAMEWORKS']
|
linkage = ENV['USE_FRAMEWORKS']
|
||||||
|
@ -35,12 +44,28 @@ target 'app' do
|
||||||
:app_path => "#{Pod::Config.instance.installation_root}/.."
|
: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
|
target 'appTests' do
|
||||||
inherit! :complete
|
inherit! :complete
|
||||||
# Pods for testing
|
# Pods for testing
|
||||||
end
|
end
|
||||||
|
|
||||||
post_install do |installer|
|
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(
|
react_native_post_install(
|
||||||
installer,
|
installer,
|
||||||
# Set `mac_catalyst_enabled` to `true` in order to apply patches
|
# 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):
|
- BVLinearGradient (2.6.2):
|
||||||
- React-Core
|
- React-Core
|
||||||
- DoubleConversion (1.1.6)
|
- DoubleConversion (1.1.6)
|
||||||
- FBLazyVector (0.71.0)
|
- FBLazyVector (0.71.1)
|
||||||
- FBReactNativeSpec (0.71.0):
|
- FBReactNativeSpec (0.71.1):
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- RCTRequired (= 0.71.0)
|
- RCTRequired (= 0.71.1)
|
||||||
- RCTTypeSafety (= 0.71.0)
|
- RCTTypeSafety (= 0.71.1)
|
||||||
- React-Core (= 0.71.0)
|
- React-Core (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/core (= 0.71.0)
|
- ReactCommon/turbomodule/core (= 0.71.1)
|
||||||
- fmt (6.2.1)
|
- fmt (6.2.1)
|
||||||
- glog (0.3.5)
|
- glog (0.3.5)
|
||||||
- hermes-engine (0.71.0):
|
- hermes-engine (0.71.1):
|
||||||
- hermes-engine/Pre-built (= 0.71.0)
|
- hermes-engine/Pre-built (= 0.71.1)
|
||||||
- hermes-engine/Pre-built (0.71.0)
|
- hermes-engine/Pre-built (0.71.1)
|
||||||
- libevent (2.1.12)
|
- 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):
|
- RCT-Folly (2021.07.22.00):
|
||||||
- boost
|
- boost
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
|
@ -34,26 +47,26 @@ PODS:
|
||||||
- fmt (~> 6.2.1)
|
- fmt (~> 6.2.1)
|
||||||
- glog
|
- glog
|
||||||
- libevent
|
- libevent
|
||||||
- RCTRequired (0.71.0)
|
- RCTRequired (0.71.1)
|
||||||
- RCTTypeSafety (0.71.0):
|
- RCTTypeSafety (0.71.1):
|
||||||
- FBLazyVector (= 0.71.0)
|
- FBLazyVector (= 0.71.1)
|
||||||
- RCTRequired (= 0.71.0)
|
- RCTRequired (= 0.71.1)
|
||||||
- React-Core (= 0.71.0)
|
- React-Core (= 0.71.1)
|
||||||
- React (0.71.0):
|
- React (0.71.1):
|
||||||
- React-Core (= 0.71.0)
|
- React-Core (= 0.71.1)
|
||||||
- React-Core/DevSupport (= 0.71.0)
|
- React-Core/DevSupport (= 0.71.1)
|
||||||
- React-Core/RCTWebSocket (= 0.71.0)
|
- React-Core/RCTWebSocket (= 0.71.1)
|
||||||
- React-RCTActionSheet (= 0.71.0)
|
- React-RCTActionSheet (= 0.71.1)
|
||||||
- React-RCTAnimation (= 0.71.0)
|
- React-RCTAnimation (= 0.71.1)
|
||||||
- React-RCTBlob (= 0.71.0)
|
- React-RCTBlob (= 0.71.1)
|
||||||
- React-RCTImage (= 0.71.0)
|
- React-RCTImage (= 0.71.1)
|
||||||
- React-RCTLinking (= 0.71.0)
|
- React-RCTLinking (= 0.71.1)
|
||||||
- React-RCTNetwork (= 0.71.0)
|
- React-RCTNetwork (= 0.71.1)
|
||||||
- React-RCTSettings (= 0.71.0)
|
- React-RCTSettings (= 0.71.1)
|
||||||
- React-RCTText (= 0.71.0)
|
- React-RCTText (= 0.71.1)
|
||||||
- React-RCTVibration (= 0.71.0)
|
- React-RCTVibration (= 0.71.1)
|
||||||
- React-callinvoker (0.71.0)
|
- React-callinvoker (0.71.1)
|
||||||
- React-Codegen (0.71.0):
|
- React-Codegen (0.71.1):
|
||||||
- FBReactNativeSpec
|
- FBReactNativeSpec
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCT-Folly
|
- RCT-Folly
|
||||||
|
@ -64,190 +77,190 @@ PODS:
|
||||||
- React-jsiexecutor
|
- React-jsiexecutor
|
||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- React-Core (0.71.0):
|
- React-Core (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default (= 0.71.0)
|
- React-Core/Default (= 0.71.1)
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/CoreModulesHeaders (0.71.0):
|
- React-Core/CoreModulesHeaders (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default
|
- React-Core/Default
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/Default (0.71.0):
|
- React-Core/Default (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/DevSupport (0.71.0):
|
- React-Core/DevSupport (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default (= 0.71.0)
|
- React-Core/Default (= 0.71.1)
|
||||||
- React-Core/RCTWebSocket (= 0.71.0)
|
- React-Core/RCTWebSocket (= 0.71.1)
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-jsinspector (= 0.71.0)
|
- React-jsinspector (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/RCTActionSheetHeaders (0.71.0):
|
- React-Core/RCTActionSheetHeaders (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default
|
- React-Core/Default
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/RCTAnimationHeaders (0.71.0):
|
- React-Core/RCTAnimationHeaders (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default
|
- React-Core/Default
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/RCTBlobHeaders (0.71.0):
|
- React-Core/RCTBlobHeaders (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default
|
- React-Core/Default
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/RCTImageHeaders (0.71.0):
|
- React-Core/RCTImageHeaders (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default
|
- React-Core/Default
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/RCTLinkingHeaders (0.71.0):
|
- React-Core/RCTLinkingHeaders (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default
|
- React-Core/Default
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/RCTNetworkHeaders (0.71.0):
|
- React-Core/RCTNetworkHeaders (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default
|
- React-Core/Default
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/RCTSettingsHeaders (0.71.0):
|
- React-Core/RCTSettingsHeaders (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default
|
- React-Core/Default
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/RCTTextHeaders (0.71.0):
|
- React-Core/RCTTextHeaders (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default
|
- React-Core/Default
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/RCTVibrationHeaders (0.71.0):
|
- React-Core/RCTVibrationHeaders (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default
|
- React-Core/Default
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-Core/RCTWebSocket (0.71.0):
|
- React-Core/RCTWebSocket (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Core/Default (= 0.71.0)
|
- React-Core/Default (= 0.71.1)
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- React-CoreModules (0.71.0):
|
- React-CoreModules (0.71.1):
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- RCTTypeSafety (= 0.71.0)
|
- RCTTypeSafety (= 0.71.1)
|
||||||
- React-Codegen (= 0.71.0)
|
- React-Codegen (= 0.71.1)
|
||||||
- React-Core/CoreModulesHeaders (= 0.71.0)
|
- React-Core/CoreModulesHeaders (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-RCTImage (= 0.71.0)
|
- React-RCTImage (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/core (= 0.71.0)
|
- ReactCommon/turbomodule/core (= 0.71.1)
|
||||||
- React-cxxreact (0.71.0):
|
- React-cxxreact (0.71.1):
|
||||||
- boost (= 1.76.0)
|
- boost (= 1.76.0)
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-callinvoker (= 0.71.0)
|
- React-callinvoker (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-jsinspector (= 0.71.0)
|
- React-jsinspector (= 0.71.1)
|
||||||
- React-logger (= 0.71.0)
|
- React-logger (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- React-runtimeexecutor (= 0.71.0)
|
- React-runtimeexecutor (= 0.71.1)
|
||||||
- React-hermes (0.71.0):
|
- React-hermes (0.71.1):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- RCT-Folly/Futures (= 2021.07.22.00)
|
- RCT-Folly/Futures (= 2021.07.22.00)
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsiexecutor (= 0.71.0)
|
- React-jsiexecutor (= 0.71.1)
|
||||||
- React-jsinspector (= 0.71.0)
|
- React-jsinspector (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- React-jsi (0.71.0):
|
- React-jsi (0.71.1):
|
||||||
- boost (= 1.76.0)
|
- boost (= 1.76.0)
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-jsiexecutor (0.71.0):
|
- React-jsiexecutor (0.71.1):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- React-jsinspector (0.71.0)
|
- React-jsinspector (0.71.1)
|
||||||
- React-logger (0.71.0):
|
- React-logger (0.71.1):
|
||||||
- glog
|
- glog
|
||||||
- react-native-blur (4.3.0):
|
- react-native-blur (4.3.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-cameraroll (5.2.2):
|
- react-native-cameraroll (5.2.4):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-image-resizer (3.0.4):
|
- react-native-image-resizer (3.0.5):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-pager-view (6.1.2):
|
- react-native-pager-view (6.1.4):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-paste-input (0.6.0):
|
- react-native-paste-input (0.6.2):
|
||||||
- React-Core
|
- React-Core
|
||||||
- Swime (= 3.0.6)
|
- Swime (= 3.0.6)
|
||||||
- react-native-safe-area-context (4.4.1):
|
- react-native-safe-area-context (4.5.0):
|
||||||
- RCT-Folly
|
- RCT-Folly
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
|
@ -257,87 +270,89 @@ PODS:
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-version-number (0.3.6):
|
- react-native-version-number (0.3.6):
|
||||||
- React
|
- React
|
||||||
- React-perflogger (0.71.0)
|
- react-native-webview (11.26.1):
|
||||||
- React-RCTActionSheet (0.71.0):
|
- React-Core
|
||||||
- React-Core/RCTActionSheetHeaders (= 0.71.0)
|
- React-perflogger (0.71.1)
|
||||||
- React-RCTAnimation (0.71.0):
|
- React-RCTActionSheet (0.71.1):
|
||||||
|
- React-Core/RCTActionSheetHeaders (= 0.71.1)
|
||||||
|
- React-RCTAnimation (0.71.1):
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- RCTTypeSafety (= 0.71.0)
|
- RCTTypeSafety (= 0.71.1)
|
||||||
- React-Codegen (= 0.71.0)
|
- React-Codegen (= 0.71.1)
|
||||||
- React-Core/RCTAnimationHeaders (= 0.71.0)
|
- React-Core/RCTAnimationHeaders (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/core (= 0.71.0)
|
- ReactCommon/turbomodule/core (= 0.71.1)
|
||||||
- React-RCTAppDelegate (0.71.0):
|
- React-RCTAppDelegate (0.71.1):
|
||||||
- RCT-Folly
|
- RCT-Folly
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
- React-Core
|
- React-Core
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- React-RCTBlob (0.71.0):
|
- React-RCTBlob (0.71.1):
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Codegen (= 0.71.0)
|
- React-Codegen (= 0.71.1)
|
||||||
- React-Core/RCTBlobHeaders (= 0.71.0)
|
- React-Core/RCTBlobHeaders (= 0.71.1)
|
||||||
- React-Core/RCTWebSocket (= 0.71.0)
|
- React-Core/RCTWebSocket (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-RCTNetwork (= 0.71.0)
|
- React-RCTNetwork (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/core (= 0.71.0)
|
- ReactCommon/turbomodule/core (= 0.71.1)
|
||||||
- React-RCTImage (0.71.0):
|
- React-RCTImage (0.71.1):
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- RCTTypeSafety (= 0.71.0)
|
- RCTTypeSafety (= 0.71.1)
|
||||||
- React-Codegen (= 0.71.0)
|
- React-Codegen (= 0.71.1)
|
||||||
- React-Core/RCTImageHeaders (= 0.71.0)
|
- React-Core/RCTImageHeaders (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-RCTNetwork (= 0.71.0)
|
- React-RCTNetwork (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/core (= 0.71.0)
|
- ReactCommon/turbomodule/core (= 0.71.1)
|
||||||
- React-RCTLinking (0.71.0):
|
- React-RCTLinking (0.71.1):
|
||||||
- React-Codegen (= 0.71.0)
|
- React-Codegen (= 0.71.1)
|
||||||
- React-Core/RCTLinkingHeaders (= 0.71.0)
|
- React-Core/RCTLinkingHeaders (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/core (= 0.71.0)
|
- ReactCommon/turbomodule/core (= 0.71.1)
|
||||||
- React-RCTNetwork (0.71.0):
|
- React-RCTNetwork (0.71.1):
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- RCTTypeSafety (= 0.71.0)
|
- RCTTypeSafety (= 0.71.1)
|
||||||
- React-Codegen (= 0.71.0)
|
- React-Codegen (= 0.71.1)
|
||||||
- React-Core/RCTNetworkHeaders (= 0.71.0)
|
- React-Core/RCTNetworkHeaders (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/core (= 0.71.0)
|
- ReactCommon/turbomodule/core (= 0.71.1)
|
||||||
- React-RCTSettings (0.71.0):
|
- React-RCTSettings (0.71.1):
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- RCTTypeSafety (= 0.71.0)
|
- RCTTypeSafety (= 0.71.1)
|
||||||
- React-Codegen (= 0.71.0)
|
- React-Codegen (= 0.71.1)
|
||||||
- React-Core/RCTSettingsHeaders (= 0.71.0)
|
- React-Core/RCTSettingsHeaders (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/core (= 0.71.0)
|
- ReactCommon/turbomodule/core (= 0.71.1)
|
||||||
- React-RCTText (0.71.0):
|
- React-RCTText (0.71.1):
|
||||||
- React-Core/RCTTextHeaders (= 0.71.0)
|
- React-Core/RCTTextHeaders (= 0.71.1)
|
||||||
- React-RCTVibration (0.71.0):
|
- React-RCTVibration (0.71.1):
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-Codegen (= 0.71.0)
|
- React-Codegen (= 0.71.1)
|
||||||
- React-Core/RCTVibrationHeaders (= 0.71.0)
|
- React-Core/RCTVibrationHeaders (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/core (= 0.71.0)
|
- ReactCommon/turbomodule/core (= 0.71.1)
|
||||||
- React-runtimeexecutor (0.71.0):
|
- React-runtimeexecutor (0.71.1):
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/bridging (0.71.0):
|
- ReactCommon/turbomodule/bridging (0.71.1):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-callinvoker (= 0.71.0)
|
- React-callinvoker (= 0.71.1)
|
||||||
- React-Core (= 0.71.0)
|
- React-Core (= 0.71.1)
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-logger (= 0.71.0)
|
- React-logger (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- ReactCommon/turbomodule/core (0.71.0):
|
- ReactCommon/turbomodule/core (0.71.1):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2021.07.22.00)
|
- RCT-Folly (= 2021.07.22.00)
|
||||||
- React-callinvoker (= 0.71.0)
|
- React-callinvoker (= 0.71.1)
|
||||||
- React-Core (= 0.71.0)
|
- React-Core (= 0.71.1)
|
||||||
- React-cxxreact (= 0.71.0)
|
- React-cxxreact (= 0.71.1)
|
||||||
- React-jsi (= 0.71.0)
|
- React-jsi (= 0.71.1)
|
||||||
- React-logger (= 0.71.0)
|
- React-logger (= 0.71.1)
|
||||||
- React-perflogger (= 0.71.0)
|
- React-perflogger (= 0.71.1)
|
||||||
- rn-fetch-blob (0.12.0):
|
- rn-fetch-blob (0.12.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNBackgroundFetch (4.1.8):
|
- RNBackgroundFetch (4.1.8):
|
||||||
|
@ -346,9 +361,13 @@ PODS:
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNCClipboard (1.11.1):
|
- RNCClipboard (1.11.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
|
- RNFastImage (8.6.3):
|
||||||
|
- React-Core
|
||||||
|
- SDWebImage (~> 5.11.1)
|
||||||
|
- SDWebImageWebPCoder (~> 0.8.4)
|
||||||
- RNFS (2.20.0):
|
- RNFS (2.20.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNGestureHandler (2.8.0):
|
- RNGestureHandler (2.9.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNImageCropPicker (0.38.1):
|
- RNImageCropPicker (0.38.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
|
@ -361,14 +380,16 @@ PODS:
|
||||||
- TOCropViewController
|
- TOCropViewController
|
||||||
- RNInAppBrowser (3.7.0):
|
- RNInAppBrowser (3.7.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNNotifee (7.4.0):
|
- RNNotifee (7.5.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNNotifee/NotifeeCore (= 7.4.0)
|
- RNNotifee/NotifeeCore (= 7.5.0)
|
||||||
- RNNotifee/NotifeeCore (7.4.0):
|
- RNNotifee/NotifeeCore (7.5.0):
|
||||||
|
- React-Core
|
||||||
|
- RNPermissions (3.6.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNReactNativeHapticFeedback (1.14.0):
|
- RNReactNativeHapticFeedback (1.14.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNReanimated (2.13.0):
|
- RNReanimated (2.14.4):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- FBLazyVector
|
- FBLazyVector
|
||||||
- FBReactNativeSpec
|
- FBReactNativeSpec
|
||||||
|
@ -395,12 +416,18 @@ PODS:
|
||||||
- React-RCTText
|
- React-RCTText
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNScreens (3.18.2):
|
- RNScreens (3.20.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- React-RCTImage
|
- React-RCTImage
|
||||||
- RNSVG (12.5.0):
|
- RNSVG (12.5.1):
|
||||||
- React-Core
|
- 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
|
- React-Core
|
||||||
- sovran-react-native
|
- sovran-react-native
|
||||||
- sovran-react-native (0.4.5):
|
- sovran-react-native (0.4.5):
|
||||||
|
@ -418,6 +445,8 @@ DEPENDENCIES:
|
||||||
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
|
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
|
||||||
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
||||||
- libevent (~> 2.1.12)
|
- 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`)
|
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
|
||||||
- RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`)
|
- RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`)
|
||||||
- RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
|
- 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-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||||
- react-native-splash-screen (from `../node_modules/react-native-splash-screen`)
|
- react-native-splash-screen (from `../node_modules/react-native-splash-screen`)
|
||||||
- react-native-version-number (from `../node_modules/react-native-version-number`)
|
- 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-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
|
||||||
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
|
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
|
||||||
- React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
|
- React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
|
||||||
|
@ -458,11 +488,13 @@ DEPENDENCIES:
|
||||||
- RNBackgroundFetch (from `../node_modules/react-native-background-fetch`)
|
- RNBackgroundFetch (from `../node_modules/react-native-background-fetch`)
|
||||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||||
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
|
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
|
||||||
|
- RNFastImage (from `../node_modules/react-native-fast-image`)
|
||||||
- RNFS (from `../node_modules/react-native-fs`)
|
- RNFS (from `../node_modules/react-native-fs`)
|
||||||
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
|
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
|
||||||
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
|
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
|
||||||
- RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
|
- RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
|
||||||
- "RNNotifee (from `../node_modules/@notifee/react-native`)"
|
- "RNNotifee (from `../node_modules/@notifee/react-native`)"
|
||||||
|
- RNPermissions (from `../node_modules/react-native-permissions`)
|
||||||
- RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
|
- RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
|
||||||
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
||||||
- RNScreens (from `../node_modules/react-native-screens`)
|
- RNScreens (from `../node_modules/react-native-screens`)
|
||||||
|
@ -475,6 +507,9 @@ SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
- fmt
|
- fmt
|
||||||
- libevent
|
- libevent
|
||||||
|
- libwebp
|
||||||
|
- SDWebImage
|
||||||
|
- SDWebImageWebPCoder
|
||||||
- Swime
|
- Swime
|
||||||
- TOCropViewController
|
- TOCropViewController
|
||||||
|
|
||||||
|
@ -493,6 +528,10 @@ EXTERNAL SOURCES:
|
||||||
:podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec"
|
:podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec"
|
||||||
hermes-engine:
|
hermes-engine:
|
||||||
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
|
: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:
|
RCT-Folly:
|
||||||
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
|
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
|
||||||
RCTRequired:
|
RCTRequired:
|
||||||
|
@ -537,6 +576,8 @@ EXTERNAL SOURCES:
|
||||||
:path: "../node_modules/react-native-splash-screen"
|
:path: "../node_modules/react-native-splash-screen"
|
||||||
react-native-version-number:
|
react-native-version-number:
|
||||||
:path: "../node_modules/react-native-version-number"
|
:path: "../node_modules/react-native-version-number"
|
||||||
|
react-native-webview:
|
||||||
|
:path: "../node_modules/react-native-webview"
|
||||||
React-perflogger:
|
React-perflogger:
|
||||||
:path: "../node_modules/react-native/ReactCommon/reactperflogger"
|
:path: "../node_modules/react-native/ReactCommon/reactperflogger"
|
||||||
React-RCTActionSheet:
|
React-RCTActionSheet:
|
||||||
|
@ -571,6 +612,8 @@ EXTERNAL SOURCES:
|
||||||
:path: "../node_modules/@react-native-async-storage/async-storage"
|
:path: "../node_modules/@react-native-async-storage/async-storage"
|
||||||
RNCClipboard:
|
RNCClipboard:
|
||||||
:path: "../node_modules/@react-native-clipboard/clipboard"
|
:path: "../node_modules/@react-native-clipboard/clipboard"
|
||||||
|
RNFastImage:
|
||||||
|
:path: "../node_modules/react-native-fast-image"
|
||||||
RNFS:
|
RNFS:
|
||||||
:path: "../node_modules/react-native-fs"
|
:path: "../node_modules/react-native-fs"
|
||||||
RNGestureHandler:
|
RNGestureHandler:
|
||||||
|
@ -581,6 +624,8 @@ EXTERNAL SOURCES:
|
||||||
:path: "../node_modules/react-native-inappbrowser-reborn"
|
:path: "../node_modules/react-native-inappbrowser-reborn"
|
||||||
RNNotifee:
|
RNNotifee:
|
||||||
:path: "../node_modules/@notifee/react-native"
|
:path: "../node_modules/@notifee/react-native"
|
||||||
|
RNPermissions:
|
||||||
|
:path: "../node_modules/react-native-permissions"
|
||||||
RNReactNativeHapticFeedback:
|
RNReactNativeHapticFeedback:
|
||||||
:path: "../node_modules/react-native-haptic-feedback"
|
:path: "../node_modules/react-native-haptic-feedback"
|
||||||
RNReanimated:
|
RNReanimated:
|
||||||
|
@ -597,69 +642,77 @@ EXTERNAL SOURCES:
|
||||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
boost: a7c83b31436843459a1961bfd74b96033dc77234
|
boost: 57d2868c099736d80fcd648bf211b4431e51a558
|
||||||
BVLinearGradient: 34a999fda29036898a09c6a6b728b0b4189e1a44
|
BVLinearGradient: 34a999fda29036898a09c6a6b728b0b4189e1a44
|
||||||
DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662
|
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
|
||||||
FBLazyVector: 61839cba7a48c570b7ac3e1cd8a4d0948382202f
|
FBLazyVector: ad72713385db5289b19f1ead07e8e4aa26dcb01d
|
||||||
FBReactNativeSpec: 5a14398ccf5e27c1ca2d7109eb920594ce93c10d
|
FBReactNativeSpec: df2602c11e33d310433496e28a48b4b2be652a61
|
||||||
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
|
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
|
||||||
glog: 476ee3e89abb49e07f822b48323c51c57124b572
|
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
|
||||||
hermes-engine: f6e715aa6c8bd38de6c13bc85e07b0a337edaa89
|
hermes-engine: 922ccd744f50d9bfde09e9677bf0f3b562ea5fb9
|
||||||
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
|
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
|
||||||
|
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
|
||||||
|
Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6
|
||||||
|
Permission-PhotoLibrary: 5b34ca67279f7201ae109cef36f9806a6596002d
|
||||||
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
|
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
|
||||||
RCTRequired: dea3e4163184ea57c50288c15c32c1529265c58f
|
RCTRequired: fd4d923b964658aa0c4091a32c8b2004c6d9e3a6
|
||||||
RCTTypeSafety: a0834ab89159a346731e8aae55ad6e2cce61c327
|
RCTTypeSafety: c276d85975bde3d8448907235c70bf0da257adfd
|
||||||
React: d877d055ff2137ca0325a4babdef3411e11f3cb7
|
React: e481a67971af1ce9639c9f746b753dd0e84ca108
|
||||||
React-callinvoker: 77bd2701eee3acac154b11ec219e68d5a1f780ad
|
React-callinvoker: 1051c04a94fa9d243786b86380606bad701a3b31
|
||||||
React-Codegen: bccc516adc1551ccfe04b0de27e345d38829b204
|
React-Codegen: 14b1e716d361d5ad95e0ce1a338f3fa0733a98b5
|
||||||
React-Core: 4035f59e5bec8f3053583c6108d99c7516deb760
|
React-Core: 698fc3baecb80d511d987475a16d036cec6d287f
|
||||||
React-CoreModules: b6a1f76423fea57a03e0d7a2f79d3b55cf193f2c
|
React-CoreModules: 59245305f41ff0adfeac334acc0594dea4585a7c
|
||||||
React-cxxreact: fe5f6ec8ae875bebc71309d1e8ef89bb966d61a6
|
React-cxxreact: 49accd2954b0f532805dbcd1918fa6962f32f247
|
||||||
React-hermes: 3c8ea5e8f402db2a08b57051206d7f2ba9c75565
|
React-hermes: d068733294581a085e95b6024e8d951b005e26d3
|
||||||
React-jsi: dbf0f82c93bfd828fa05c50f2ee74dc81f711050
|
React-jsi: 122b9bce14f4c6c7cb58f28f87912cfe091885fa
|
||||||
React-jsiexecutor: 060dd495f1e2af3d87216f7ca8a94c55ec885b4f
|
React-jsiexecutor: 60cf272aababc5212410e4249d17cea14fc36caa
|
||||||
React-jsinspector: 5061fcbec93fd672183dfb39cc2f65e55a0835db
|
React-jsinspector: ff56004b0c974b688a6548c156d5830ad751ae07
|
||||||
React-logger: a6c0b3a807a8e81f6d7fea2e72660766f55daa50
|
React-logger: 60a0b5f8bed667ecf9e24fecca1f30d125de6d75
|
||||||
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
|
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
|
||||||
react-native-cameraroll: 71d68167beb6fc7216aa564abb6d86f1d666a2c6
|
react-native-cameraroll: cb752fda6d5268f1646b4390bd5be1f27706b9a0
|
||||||
react-native-image-resizer: 794abf75ec13ed1f0dbb1f134e27504ea65e9e66
|
react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa
|
||||||
react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43
|
react-native-pager-view: b58cb9e9f42f64e50cab3040815772c1d119a2e2
|
||||||
react-native-paste-input: 5182843692fd2ec72be50f241a38a49796e225d7
|
react-native-paste-input: 3392800944a47c00dddbff23c31c281482209679
|
||||||
react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a
|
react-native-safe-area-context: 39c2d8be3328df5d437ac1700f4f3a4f75716acc
|
||||||
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
|
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
|
||||||
react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f
|
react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f
|
||||||
React-perflogger: e5fc4149e9bbb972b8520277f3b23141faa47a36
|
react-native-webview: 9f111dfbcfc826084d6c507f569e5e03342ee1c1
|
||||||
React-RCTActionSheet: 991de88216bf03ab9bb1d213d73c62ecbe64ade7
|
React-perflogger: ec8eef2a8f03ecfa6361c2c5fb9197ef4a29cc85
|
||||||
React-RCTAnimation: b74e3d1bf5280891a573e447b487fa1db0713b5b
|
React-RCTActionSheet: a0c023b86cf4c862fa9c4eb0f6f91fbe878fb2de
|
||||||
React-RCTAppDelegate: f52667f2dbc510f87b7988c5204e8764d50bf0c1
|
React-RCTAnimation: 168d53718c74153947c0109f55900faa64d79439
|
||||||
React-RCTBlob: 6762787c01d5d8d18efed03764b0d58d3b79595a
|
React-RCTAppDelegate: a8efbab128b34aa07a9491c85a41401210b1bec5
|
||||||
React-RCTImage: 9ed7eba8dd192a49def2cad2ecaedee7e7e315b4
|
React-RCTBlob: 9bcbfc893bfda9f6b2eb016329d38c0f6366d31a
|
||||||
React-RCTLinking: 0b58eed9af0645a161b80bf412b6b721e4585c66
|
React-RCTImage: 3fcd4570b4b0f1ac2f4b4b6308dba33ce66c5b50
|
||||||
React-RCTNetwork: dc075b0eea00d8a98c928f011d9bc2458acc7092
|
React-RCTLinking: 1edb8e1bb3fc39bf9e13c63d6aaaa3f0c3d18683
|
||||||
React-RCTSettings: 30fb3f498cfaf8a4bb47334ff9ffbe318ef78766
|
React-RCTNetwork: 500a79e0e0f67678077df727fabba87a55c043e1
|
||||||
React-RCTText: a631564e84a227fe24bae7c04446f36faea7fcf5
|
React-RCTSettings: cc4414eb84ad756d619076c3999fecbf12896d6f
|
||||||
React-RCTVibration: 55c91eccdbd435d7634efbe847086944389475b0
|
React-RCTText: 2a34261f3da6e34f47a62154def657546ebfa5e1
|
||||||
React-runtimeexecutor: ac80782d9d76ba2b0f709f4de0c427fe33c352dc
|
React-RCTVibration: 49d531ec8498e0afa2c9b22c2205784372e3d4f3
|
||||||
ReactCommon: 20e38a9be5fe1341b5e422220877cc94034776ba
|
React-runtimeexecutor: 311feb67600774723fe10eb8801d3138cae9ad67
|
||||||
|
ReactCommon: 03be76588338a27a88d103b35c3c44a3fd43d136
|
||||||
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
||||||
RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
|
RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
|
||||||
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
|
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
|
||||||
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
|
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
|
||||||
|
RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8
|
||||||
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
||||||
RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3
|
RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
|
||||||
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
|
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
|
||||||
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
|
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
|
||||||
RNNotifee: da8dcf09f079ea22f46e239d7c406e10d4525a5f
|
RNNotifee: 053c0ace9c73634709a0214fd9c436a5777a562f
|
||||||
|
RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c
|
||||||
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
|
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
|
||||||
RNReanimated: d8d9d3d3801bda5e35e85cdffc871577d044dc2e
|
RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128
|
||||||
RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
|
RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
|
||||||
RNSVG: 6adc5c52d2488a476248413064b7f2832e639057
|
RNSVG: d7d7bc8229af3842c9cfc3a723c815a52cdd1105
|
||||||
segment-analytics-react-native: cb097e393c3560a0d4cfd877044293e37b0050d9
|
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
|
||||||
|
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
|
||||||
|
segment-analytics-react-native: bd1f13ea95bad2313a9c7130da032af0e9a6da60
|
||||||
sovran-react-native: fd3dc8f1a4b14acdc4ad25fc6b4ac4f52a2a2a15
|
sovran-react-native: fd3dc8f1a4b14acdc4ad25fc6b4ac4f52a2a2a15
|
||||||
Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b
|
Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b
|
||||||
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
|
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
|
||||||
Yoga: c618b544ff8bd8865cdca602f00cbcdb92fd6d31
|
Yoga: 921eb014669cf9c718ada68b08d362517d564e0c
|
||||||
|
|
||||||
PODFILE CHECKSUM: 0975a639c66f07f4d49706dd0bf7c3aa4dc833cf
|
PODFILE CHECKSUM: 95c7fde1130d862b561348cca2b3fb7f9bd84bfb
|
||||||
|
|
||||||
COCOAPODS: 1.11.3
|
COCOAPODS: 1.11.3
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
[[TSBackgroundFetch sharedInstance] didFinishLaunching];
|
[[TSBackgroundFetch sharedInstance] didFinishLaunching];
|
||||||
|
|
||||||
self.moduleName = @"xyz.blueskyweb.app";
|
self.moduleName = @"xyz.blueskyweb.app";
|
||||||
|
self.initialProps = @{};
|
||||||
return [super application:application didFinishLaunchingWithOptions:launchOptions];
|
return [super application:application didFinishLaunchingWithOptions:launchOptions];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>1.2</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
@ -39,6 +39,8 @@
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
|
|
@ -1,10 +1,19 @@
|
||||||
/* global jest */
|
/* global jest */
|
||||||
|
import {configure} from '@testing-library/react-native'
|
||||||
import 'react-native-gesture-handler/jestSetup'
|
import 'react-native-gesture-handler/jestSetup'
|
||||||
|
|
||||||
|
configure({asyncUtilTimeout: 20000})
|
||||||
|
|
||||||
jest.mock('@react-native-async-storage/async-storage', () =>
|
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||||
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
|
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
|
// Silence the warning: Animated: `useNativeDriver` is not supported
|
||||||
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper')
|
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper')
|
||||||
|
@ -55,3 +64,7 @@ jest.mock('@segment/analytics-react-native', () => ({
|
||||||
flush: jest.fn(),
|
flush: jest.fn(),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
jest.mock('react-native-permissions', () =>
|
||||||
|
require('react-native-permissions/mock'),
|
||||||
|
)
|
||||||
|
|
|
@ -8,7 +8,7 @@ import PDSServer, {
|
||||||
ServerConfig as PDSServerConfig,
|
ServerConfig as PDSServerConfig,
|
||||||
} from '@atproto/pds'
|
} from '@atproto/pds'
|
||||||
import * as plc from '@atproto/plc'
|
import * as plc from '@atproto/plc'
|
||||||
import AtpApi, {ServiceClient} from '@atproto/api'
|
import AtpAgent from '@atproto/api'
|
||||||
|
|
||||||
export interface TestUser {
|
export interface TestUser {
|
||||||
email: string
|
email: string
|
||||||
|
@ -16,7 +16,7 @@ export interface TestUser {
|
||||||
declarationCid: string
|
declarationCid: string
|
||||||
handle: string
|
handle: string
|
||||||
password: string
|
password: string
|
||||||
api: ServiceClient
|
agent: AtpAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestUsers {
|
export interface TestUsers {
|
||||||
|
@ -87,6 +87,8 @@ export async function createServer(): Promise<TestPDS> {
|
||||||
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
||||||
blobstoreLocation: `${blobstoreLoc}/blobs`,
|
blobstoreLocation: `${blobstoreLoc}/blobs`,
|
||||||
blobstoreTmp: `${blobstoreLoc}/tmp`,
|
blobstoreTmp: `${blobstoreLoc}/tmp`,
|
||||||
|
maxSubscriptionBuffer: 200,
|
||||||
|
repoBackfillLimitMs: 1e3 * 60 * 60,
|
||||||
})
|
})
|
||||||
|
|
||||||
const db = PDSDatabase.memory()
|
const db = PDSDatabase.memory()
|
||||||
|
@ -112,11 +114,11 @@ export async function createServer(): Promise<TestPDS> {
|
||||||
async function genMockData(pdsUrl: string): Promise<TestUsers> {
|
async function genMockData(pdsUrl: string): Promise<TestUsers> {
|
||||||
const date = dateGen()
|
const date = dateGen()
|
||||||
|
|
||||||
const clients = {
|
const agents = {
|
||||||
loggedout: AtpApi.service(pdsUrl),
|
loggedout: new AtpAgent({service: pdsUrl}),
|
||||||
alice: AtpApi.service(pdsUrl),
|
alice: new AtpAgent({service: pdsUrl}),
|
||||||
bob: AtpApi.service(pdsUrl),
|
bob: new AtpAgent({service: pdsUrl}),
|
||||||
carla: AtpApi.service(pdsUrl),
|
carla: new AtpAgent({service: pdsUrl}),
|
||||||
}
|
}
|
||||||
const users: TestUser[] = [
|
const users: TestUser[] = [
|
||||||
{
|
{
|
||||||
|
@ -125,7 +127,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
|
||||||
declarationCid: '',
|
declarationCid: '',
|
||||||
handle: 'alice.test',
|
handle: 'alice.test',
|
||||||
password: 'hunter2',
|
password: 'hunter2',
|
||||||
api: clients.alice,
|
agent: agents.alice,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: 'bob@test.com',
|
email: 'bob@test.com',
|
||||||
|
@ -133,7 +135,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
|
||||||
declarationCid: '',
|
declarationCid: '',
|
||||||
handle: 'bob.test',
|
handle: 'bob.test',
|
||||||
password: 'hunter2',
|
password: 'hunter2',
|
||||||
api: clients.bob,
|
agent: agents.bob,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: 'carla@test.com',
|
email: 'carla@test.com',
|
||||||
|
@ -141,7 +143,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
|
||||||
declarationCid: '',
|
declarationCid: '',
|
||||||
handle: 'carla.test',
|
handle: 'carla.test',
|
||||||
password: 'hunter2',
|
password: 'hunter2',
|
||||||
api: clients.carla,
|
agent: agents.carla,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const alice = users[0]
|
const alice = users[0]
|
||||||
|
@ -150,18 +152,18 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
|
||||||
|
|
||||||
let _i = 1
|
let _i = 1
|
||||||
for (const user of users) {
|
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,
|
email: user.email,
|
||||||
handle: user.handle,
|
handle: user.handle,
|
||||||
password: user.password,
|
password: user.password,
|
||||||
})
|
})
|
||||||
user.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
user.agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
||||||
const {data: profile} = await user.api.app.bsky.actor.getProfile({
|
const {data: profile} = await user.agent.api.app.bsky.actor.getProfile({
|
||||||
actor: user.handle,
|
actor: user.handle,
|
||||||
})
|
})
|
||||||
user.did = res.data.did
|
user.did = res.data.did
|
||||||
user.declarationCid = profile.declaration.cid
|
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},
|
{did: user.did},
|
||||||
{
|
{
|
||||||
displayName: ucfirst(user.handle).slice(0, -5),
|
displayName: ucfirst(user.handle).slice(0, -5),
|
||||||
|
@ -172,7 +174,7 @@ async function genMockData(pdsUrl: string): Promise<TestUsers> {
|
||||||
|
|
||||||
// everybody follows everybody
|
// everybody follows everybody
|
||||||
const follow = async (author: TestUser, subject: TestUser) => {
|
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},
|
{did: author.did},
|
||||||
{
|
{
|
||||||
subject: {
|
subject: {
|
||||||
|
|
|
@ -3,17 +3,15 @@ import {render} from '@testing-library/react-native'
|
||||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||||
import {RootStoreProvider} from '../src/state'
|
import {RootStoreProvider, RootStoreModel} from '../src/state'
|
||||||
import {ThemeProvider} from '../src/view/lib/ThemeContext'
|
import {ThemeProvider} from '../src/lib/ThemeContext'
|
||||||
import {mockedRootStore} from '../__mocks__/state-mock'
|
|
||||||
|
|
||||||
const customRender = (ui: any, rootStore?: any) =>
|
const customRender = (ui: any, rootStore: RootStoreModel) =>
|
||||||
render(
|
render(
|
||||||
// eslint-disable-next-line react-native/no-inline-styles
|
// eslint-disable-next-line react-native/no-inline-styles
|
||||||
<GestureHandlerRootView style={{flex: 1}}>
|
<GestureHandlerRootView style={{flex: 1}}>
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
<RootStoreProvider
|
<RootStoreProvider value={rootStore}>
|
||||||
value={rootStore != null ? rootStore : mockedRootStore}>
|
|
||||||
<ThemeProvider theme="light">
|
<ThemeProvider theme="light">
|
||||||
<SafeAreaProvider>{ui}</SafeAreaProvider>
|
<SafeAreaProvider>{ui}</SafeAreaProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
45
package.json
45
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "0.0.1",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
@ -8,16 +8,17 @@
|
||||||
"web": "webpack-dev-server --config ./web/webpack.config.js -d inline-source-map --hot --color",
|
"web": "webpack-dev-server --config ./web/webpack.config.js -d inline-source-map --hot --color",
|
||||||
"start": "react-native start",
|
"start": "react-native start",
|
||||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||||
"test": "jest --forceExit",
|
"test": "jest --forceExit --testTimeout=20000 --bail",
|
||||||
"test-watch": "jest --watchAll",
|
"test-watch": "jest --watchAll",
|
||||||
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||||
"test-coverage": "jest --coverage",
|
"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": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.0.6",
|
"@atproto/api": "^0.1.2",
|
||||||
"@atproto/lexicon": "^0.0.4",
|
"@atproto/lexicon": "^0.0.4",
|
||||||
"@atproto/xrpc": "^0.0.3",
|
"@atproto/xrpc": "^0.0.4",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||||
|
@ -34,21 +35,27 @@
|
||||||
"@segment/analytics-react-native": "^2.10.1",
|
"@segment/analytics-react-native": "^2.10.1",
|
||||||
"@segment/sovran-react-native": "^0.4.5",
|
"@segment/sovran-react-native": "^0.4.5",
|
||||||
"@zxing/text-encoding": "^0.9.0",
|
"@zxing/text-encoding": "^0.9.0",
|
||||||
|
"await-lock": "^2.2.2",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"email-validator": "^2.0.4",
|
"email-validator": "^2.0.4",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"lodash.chunk": "^4.2.0",
|
"lodash.chunk": "^4.2.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
"lodash.omit": "^4.5.0",
|
"lodash.omit": "^4.5.0",
|
||||||
|
"lodash.shuffle": "^4.2.0",
|
||||||
"lru_map": "^0.4.1",
|
"lru_map": "^0.4.1",
|
||||||
"mobx": "^6.6.1",
|
"mobx": "^6.6.1",
|
||||||
"mobx-react-lite": "^3.4.0",
|
"mobx-react-lite": "^3.4.0",
|
||||||
|
"normalize-url": "^8.0.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-avatar-editor": "^13.0.0",
|
"react-avatar-editor": "^13.0.0",
|
||||||
"react-circular-progressbar": "^2.1.0",
|
"react-circular-progressbar": "^2.1.0",
|
||||||
"react-dom": "^18.2.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-appstate-hook": "^1.0.6",
|
||||||
"react-native-background-fetch": "^4.1.8",
|
"react-native-background-fetch": "^4.1.8",
|
||||||
|
"react-native-fast-image": "^8.6.3",
|
||||||
"react-native-fs": "^2.20.0",
|
"react-native-fs": "^2.20.0",
|
||||||
"react-native-gesture-handler": "^2.5.0",
|
"react-native-gesture-handler": "^2.5.0",
|
||||||
"react-native-haptic-feedback": "^1.14.0",
|
"react-native-haptic-feedback": "^1.14.0",
|
||||||
|
@ -56,6 +63,7 @@
|
||||||
"react-native-inappbrowser-reborn": "^3.6.3",
|
"react-native-inappbrowser-reborn": "^3.6.3",
|
||||||
"react-native-linear-gradient": "^2.6.2",
|
"react-native-linear-gradient": "^2.6.2",
|
||||||
"react-native-pager-view": "^6.0.2",
|
"react-native-pager-view": "^6.0.2",
|
||||||
|
"react-native-permissions": "^3.6.1",
|
||||||
"react-native-progress": "^5.0.0",
|
"react-native-progress": "^5.0.0",
|
||||||
"react-native-reanimated": "^2.9.1",
|
"react-native-reanimated": "^2.9.1",
|
||||||
"react-native-root-siblings": "^4.1.1",
|
"react-native-root-siblings": "^4.1.1",
|
||||||
|
@ -66,18 +74,22 @@
|
||||||
"react-native-svg": "^12.4.0",
|
"react-native-svg": "^12.4.0",
|
||||||
"react-native-tab-view": "^3.3.0",
|
"react-native-tab-view": "^3.3.0",
|
||||||
"react-native-url-polyfill": "^1.3.0",
|
"react-native-url-polyfill": "^1.3.0",
|
||||||
|
"react-native-uuid": "^2.0.1",
|
||||||
"react-native-version-number": "^0.3.6",
|
"react-native-version-number": "^0.3.6",
|
||||||
"react-native-web": "^0.18.11",
|
"react-native-web": "^0.18.11",
|
||||||
"react-native-web-linear-gradient": "^1.1.2",
|
"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",
|
"rn-fetch-blob": "^0.12.0",
|
||||||
"tlds": "^1.234.0",
|
"tlds": "^1.234.0",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@atproto/pds": "^0.0.1",
|
"@atproto/pds": "^0.0.3",
|
||||||
"@babel/core": "^7.12.9",
|
"@babel/core": "^7.20.0",
|
||||||
"@babel/preset-env": "^7.14.0",
|
"@babel/preset-env": "^7.20.0",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.20.0",
|
||||||
"@react-native-community/eslint-config": "^3.0.0",
|
"@react-native-community/eslint-config": "^3.0.0",
|
||||||
"@testing-library/jest-native": "^5.3.3",
|
"@testing-library/jest-native": "^5.3.3",
|
||||||
"@testing-library/react-native": "^11.5.0",
|
"@testing-library/react-native": "^11.5.0",
|
||||||
|
@ -85,7 +97,10 @@
|
||||||
"@types/he": "^1.1.2",
|
"@types/he": "^1.1.2",
|
||||||
"@types/jest": "^26.0.23",
|
"@types/jest": "^26.0.23",
|
||||||
"@types/lodash.chunk": "^4.2.7",
|
"@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.omit": "^4.5.7",
|
||||||
|
"@types/lodash.shuffle": "^4.2.7",
|
||||||
"@types/react-avatar-editor": "^13.0.0",
|
"@types/react-avatar-editor": "^13.0.0",
|
||||||
"@types/react-native": "^0.67.3",
|
"@types/react-native": "^0.67.3",
|
||||||
"@types/react-test-renderer": "^17.0.1",
|
"@types/react-test-renderer": "^17.0.1",
|
||||||
|
@ -95,11 +110,14 @@
|
||||||
"babel-loader": "^9.1.2",
|
"babel-loader": "^9.1.2",
|
||||||
"babel-plugin-module-resolver": "^5.0.0",
|
"babel-plugin-module-resolver": "^5.0.0",
|
||||||
"babel-plugin-react-native-web": "^0.18.12",
|
"babel-plugin-react-native-web": "^0.18.12",
|
||||||
|
"detox": "^20.1.2",
|
||||||
"eslint": "^8.19.0",
|
"eslint": "^8.19.0",
|
||||||
|
"eslint-plugin-detox": "^1.0.0",
|
||||||
|
"eslint-plugin-ft-flow": "^2.0.3",
|
||||||
"html-webpack-plugin": "^5.5.0",
|
"html-webpack-plugin": "^5.5.0",
|
||||||
"jest": "^29.2.1",
|
"jest": "^29.2.1",
|
||||||
"jest-junit": "^15.0.0",
|
"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",
|
"prettier": "^2.8.3",
|
||||||
"react-native-dotenv": "^3.3.1",
|
"react-native-dotenv": "^3.3.1",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
|
@ -131,10 +149,11 @@
|
||||||
"node"
|
"node"
|
||||||
],
|
],
|
||||||
"transformIgnorePatterns": [
|
"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": [
|
"modulePathIgnorePatterns": [
|
||||||
"__tests__/.*/__mocks__"
|
"__tests__/.*/__mocks__",
|
||||||
|
"e2e/.*"
|
||||||
],
|
],
|
||||||
"coveragePathIgnorePatterns": [
|
"coveragePathIgnorePatterns": [
|
||||||
"<rootDir>/node_modules/",
|
"<rootDir>/node_modules/",
|
||||||
|
|
|
@ -6,35 +6,27 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||||
import SplashScreen from 'react-native-splash-screen'
|
import SplashScreen from 'react-native-splash-screen'
|
||||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {
|
import {ThemeProvider} from 'lib/ThemeContext'
|
||||||
createClient,
|
|
||||||
SegmentClient,
|
|
||||||
AnalyticsProvider,
|
|
||||||
} from '@segment/analytics-react-native'
|
|
||||||
import {ThemeProvider} from './view/lib/ThemeContext'
|
|
||||||
import * as view from './view/index'
|
import * as view from './view/index'
|
||||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||||
import {MobileShell} from './view/shell/mobile'
|
import {MobileShell} from './view/shell/mobile'
|
||||||
import {s} from './view/lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import notifee, {EventType} from '@notifee/react-native'
|
import * as notifee from 'lib/notifee'
|
||||||
|
import * as analytics from 'lib/analytics'
|
||||||
|
import * as Toast from './view/com/util/Toast'
|
||||||
|
|
||||||
const App = observer(() => {
|
const App = observer(() => {
|
||||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
const [segment, setSegment] = useState<SegmentClient | undefined>(undefined)
|
|
||||||
|
|
||||||
// init
|
// init
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
view.setup()
|
view.setup()
|
||||||
setSegment(
|
|
||||||
createClient({
|
|
||||||
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
|
|
||||||
trackAppLifecycleEvents: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setupState().then(store => {
|
setupState().then(store => {
|
||||||
setRootStore(store)
|
setRootStore(store)
|
||||||
|
analytics.init(store)
|
||||||
|
notifee.init(store)
|
||||||
SplashScreen.hide()
|
SplashScreen.hide()
|
||||||
Linking.getInitialURL().then((url: string | null) => {
|
Linking.getInitialURL().then((url: string | null) => {
|
||||||
if (url) {
|
if (url) {
|
||||||
|
@ -44,12 +36,8 @@ const App = observer(() => {
|
||||||
Linking.addEventListener('url', ({url}) => {
|
Linking.addEventListener('url', ({url}) => {
|
||||||
store.nav.handleLink(url)
|
store.nav.handleLink(url)
|
||||||
})
|
})
|
||||||
notifee.onForegroundEvent(async ({type}: {type: EventType}) => {
|
store.onSessionDropped(() => {
|
||||||
store.log.debug('Notifee foreground event', {type})
|
Toast.show('Sorry! Your session expired. Please log in again.')
|
||||||
if (type === EventType.PRESS) {
|
|
||||||
store.log.debug('User pressed a notifee, opening notifications')
|
|
||||||
store.nav.switchTo(1, true)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
@ -58,20 +46,19 @@ const App = observer(() => {
|
||||||
if (!rootStore) {
|
if (!rootStore) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={s.h100pct}>
|
<GestureHandlerRootView style={s.h100pct}>
|
||||||
<RootSiblingParent>
|
<ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}>
|
||||||
<AnalyticsProvider client={segment}>
|
<RootSiblingParent>
|
||||||
<RootStoreProvider value={rootStore}>
|
<analytics.Provider>
|
||||||
<ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}>
|
<RootStoreProvider value={rootStore}>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<MobileShell />
|
<MobileShell />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</ThemeProvider>
|
</RootStoreProvider>
|
||||||
</RootStoreProvider>
|
</analytics.Provider>
|
||||||
</AnalyticsProvider>
|
</RootSiblingParent>
|
||||||
</RootSiblingParent>
|
</ThemeProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -53,6 +53,7 @@ export type TypographyVariant =
|
||||||
| 'xs-medium'
|
| 'xs-medium'
|
||||||
| 'xs-bold'
|
| 'xs-bold'
|
||||||
| 'xs-heavy'
|
| 'xs-heavy'
|
||||||
|
| 'title-2xl'
|
||||||
| 'title-xl'
|
| 'title-xl'
|
||||||
| 'title-lg'
|
| 'title-lg'
|
||||||
| 'title'
|
| 'title'
|
||||||
|
@ -60,6 +61,7 @@ export type TypographyVariant =
|
||||||
| 'post-text-lg'
|
| 'post-text-lg'
|
||||||
| 'post-text'
|
| 'post-text'
|
||||||
| 'button'
|
| 'button'
|
||||||
|
| 'button-lg'
|
||||||
| 'mono'
|
| 'mono'
|
||||||
export type Typography = Record<TypographyVariant, TextStyle>
|
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'
|
import RNFS from 'react-native-fs'
|
||||||
|
|
||||||
const TIMEOUT = 10e3 // 10s
|
const TIMEOUT = 10e3 // 10s
|
||||||
|
|
||||||
export function doPolyfill() {
|
export function doPolyfill() {
|
||||||
AtpApi.xrpc.fetch = fetchHandler
|
AtpAgent.configure({fetch: fetchHandler})
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FetchHandlerResponse {
|
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 {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
|
||||||
import {AtUri} from '../../third-party/uri'
|
import {AtUri} from '../../third-party/uri'
|
||||||
import {RootStoreModel} from '../models/root-store'
|
import {RootStoreModel} from 'state/models/root-store'
|
||||||
import {extractEntities} from '../../lib/strings'
|
import {extractEntities} from 'lib/strings/rich-text-detection'
|
||||||
import {isNetworkError} from '../../lib/errors'
|
import {isNetworkError} from 'lib/strings/errors'
|
||||||
import {LinkMeta} from '../../lib/link-meta'
|
import {LinkMeta} from '../link-meta/link-meta'
|
||||||
import {Image} from '../../lib/images'
|
import {Image} from '../images'
|
||||||
|
import {RichText} from '../strings/rich-text'
|
||||||
|
|
||||||
export interface ExternalEmbedDraft {
|
export interface ExternalEmbedDraft {
|
||||||
uri: string
|
uri: string
|
||||||
|
@ -19,9 +14,22 @@ export interface ExternalEmbedDraft {
|
||||||
localThumb?: Image
|
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(
|
export async function post(
|
||||||
store: RootStoreModel,
|
store: RootStoreModel,
|
||||||
text: string,
|
rawText: string,
|
||||||
replyTo?: string,
|
replyTo?: string,
|
||||||
extLink?: ExternalEmbedDraft,
|
extLink?: ExternalEmbedDraft,
|
||||||
images?: string[],
|
images?: string[],
|
||||||
|
@ -30,6 +38,9 @@ export async function post(
|
||||||
) {
|
) {
|
||||||
let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined
|
let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined
|
||||||
let reply
|
let reply
|
||||||
|
const text = new RichText(rawText, undefined, {
|
||||||
|
cleanNewlines: true,
|
||||||
|
}).text.trim()
|
||||||
|
|
||||||
onStateChange?.('Processing...')
|
onStateChange?.('Processing...')
|
||||||
const entities = extractEntities(text, knownHandles)
|
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(
|
export function configure(
|
||||||
handler: (taskId: string) => Promise<void>,
|
handler: (taskId: string) => Promise<void>,
|
||||||
timeoutHandler: (taskId: string) => Promise<void>,
|
timeoutHandler: (taskId: string) => void,
|
||||||
): Promise<BackgroundFetchStatus> {
|
): Promise<BackgroundFetchStatus> {
|
||||||
return BackgroundFetch.configure(
|
return BackgroundFetch.configure(
|
||||||
{minimumFetchInterval: 15},
|
{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 {useState} from 'react'
|
||||||
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
||||||
import {RootStoreModel} from '../../../state'
|
import {RootStoreModel} from 'state/index'
|
||||||
|
|
||||||
export type OnScrollCb = (
|
export type OnScrollCb = (
|
||||||
event: NativeSyntheticEvent<NativeScrollEvent>,
|
event: NativeSyntheticEvent<NativeScrollEvent>,
|
|
@ -2,8 +2,8 @@ import RNFetchBlob from 'rn-fetch-blob'
|
||||||
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||||
import {Share} from 'react-native'
|
import {Share} from 'react-native'
|
||||||
import RNFS from 'react-native-fs'
|
import RNFS from 'react-native-fs'
|
||||||
|
import uuid from 'react-native-uuid'
|
||||||
import * as Toast from '../view/com/util/Toast'
|
import * as Toast from 'view/com/util/Toast'
|
||||||
|
|
||||||
export interface DownloadAndResizeOpts {
|
export interface DownloadAndResizeOpts {
|
||||||
uri: string
|
uri: string
|
||||||
|
@ -23,16 +23,12 @@ export interface Image {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||||
let appendExt
|
let appendExt = 'jpeg'
|
||||||
try {
|
try {
|
||||||
const urip = new URL(opts.uri)
|
const urip = new URL(opts.uri)
|
||||||
const ext = urip.pathname.split('.').pop()
|
const ext = urip.pathname.split('.').pop()
|
||||||
if (ext === 'jpg' || ext === 'jpeg') {
|
if (ext === 'png') {
|
||||||
appendExt = 'jpeg'
|
|
||||||
} else if (ext === 'png') {
|
|
||||||
appendExt = 'png'
|
appendExt = 'png'
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Invalid URI', opts.uri, e)
|
console.error('Invalid URI', opts.uri, e)
|
||||||
|
@ -109,12 +105,18 @@ export async function compressIfNeeded(
|
||||||
if (img.size < maxSize) {
|
if (img.size < maxSize) {
|
||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
return await resize(origUri, {
|
const resizedImage = await resize(origUri, {
|
||||||
width: img.width,
|
width: img.width,
|
||||||
height: img.height,
|
height: img.height,
|
||||||
mode: 'stretch',
|
mode: 'stretch',
|
||||||
maxSize,
|
maxSize,
|
||||||
})
|
})
|
||||||
|
const finalImageMovedPath = await moveToPremanantPath(resizedImage.path)
|
||||||
|
const finalImg = {
|
||||||
|
...resizedImage,
|
||||||
|
path: finalImageMovedPath,
|
||||||
|
}
|
||||||
|
return finalImg
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Dim {
|
export interface Dim {
|
||||||
|
@ -150,3 +152,15 @@ export const saveImageModal = async ({uri}: {uri: string}) => {
|
||||||
}
|
}
|
||||||
RNFS.unlink(imagePath)
|
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 {Share} from 'react-native'
|
||||||
|
// import * as Toast from 'view/com/util/Toast'
|
||||||
import * as Toast from '../view/com/util/Toast'
|
|
||||||
|
|
||||||
export interface DownloadAndResizeOpts {
|
export interface DownloadAndResizeOpts {
|
||||||
uri: string
|
uri: string
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import {LikelyType, LinkMeta} from './link-meta'
|
import {LikelyType, LinkMeta} from './link-meta'
|
||||||
import {match as matchRoute} from '../view/routes'
|
import {match as matchRoute} from 'view/routes'
|
||||||
import {convertBskyAppUrlIfNeeded, makeRecordUri} from './strings'
|
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
|
||||||
import {RootStoreModel} from '../state'
|
import {RootStoreModel} from 'state/index'
|
||||||
import {PostThreadViewModel} from '../state/models/post-thread-view'
|
import {PostThreadViewModel} from 'state/models/post-thread-view'
|
||||||
|
|
||||||
import {Home} from '../view/screens/Home'
|
import {Home} from 'view/screens/Home'
|
||||||
import {Search} from '../view/screens/Search'
|
import {Search} from 'view/screens/Search'
|
||||||
import {Notifications} from '../view/screens/Notifications'
|
import {Notifications} from 'view/screens/Notifications'
|
||||||
import {PostThread} from '../view/screens/PostThread'
|
import {PostThread} from 'view/screens/PostThread'
|
||||||
import {PostUpvotedBy} from '../view/screens/PostUpvotedBy'
|
import {PostUpvotedBy} from 'view/screens/PostUpvotedBy'
|
||||||
import {PostRepostedBy} from '../view/screens/PostRepostedBy'
|
import {PostRepostedBy} from 'view/screens/PostRepostedBy'
|
||||||
import {Profile} from '../view/screens/Profile'
|
import {Profile} from 'view/screens/Profile'
|
||||||
import {ProfileFollowers} from '../view/screens/ProfileFollowers'
|
import {ProfileFollowers} from 'view/screens/ProfileFollowers'
|
||||||
import {ProfileFollows} from '../view/screens/ProfileFollows'
|
import {ProfileFollows} from 'view/screens/ProfileFollows'
|
||||||
|
|
||||||
// NOTE
|
// NOTE
|
||||||
// this is a hack around the lack of hosted social metadata
|
// this is a hack around the lack of hosted social metadata
|
|
@ -1,5 +1,5 @@
|
||||||
import {extractTwitterMeta} from './extractTwitterMeta'
|
import {extractTwitterMeta} from './twitter'
|
||||||
import {extractYoutubeMeta} from './extractYoutubeMeta'
|
import {extractYoutubeMeta} from './youtube'
|
||||||
|
|
||||||
interface ExtractHtmlMetaInput {
|
interface ExtractHtmlMetaInput {
|
||||||
html: string
|
html: string
|
|
@ -1,8 +1,8 @@
|
||||||
import he from 'he'
|
import he from 'he'
|
||||||
import {isBskyAppUrl} from './strings'
|
import {isBskyAppUrl} from '../strings/url-helpers'
|
||||||
import {RootStoreModel} from '../state'
|
import {RootStoreModel} from 'state/index'
|
||||||
import {extractBskyMeta} from './extractBskyMeta'
|
import {extractBskyMeta} from './bsky'
|
||||||
import {extractHtmlMeta} from './extractHtmlMeta'
|
import {extractHtmlMeta} from './html'
|
||||||
|
|
||||||
export enum LikelyType {
|
export enum LikelyType {
|
||||||
HTML,
|
HTML,
|
|
@ -4,10 +4,12 @@ export const extractYoutubeMeta = (html: string): Record<string, string> => {
|
||||||
const youtubeDescriptionRegex =
|
const youtubeDescriptionRegex =
|
||||||
/"videoDetails":.*"shortDescription":"([^"]*)"/i
|
/"videoDetails":.*"shortDescription":"([^"]*)"/i
|
||||||
const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i
|
const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i
|
||||||
|
const youtubeAvatarRegex =
|
||||||
|
/"avatar":{"thumbnails":\[{.*?url.*?url.*?url":"([^"]*)"/i
|
||||||
const youtubeTitleMatch = youtubeTitleRegex.exec(html)
|
const youtubeTitleMatch = youtubeTitleRegex.exec(html)
|
||||||
const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html)
|
const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html)
|
||||||
const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html)
|
const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html)
|
||||||
|
const youtubeAvatarMatch = youtubeAvatarRegex.exec(html)
|
||||||
|
|
||||||
if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) {
|
if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) {
|
||||||
res.title = decodeURI(youtubeTitleMatch[1])
|
res.title = decodeURI(youtubeTitleMatch[1])
|
||||||
|
@ -21,6 +23,9 @@ export const extractYoutubeMeta = (html: string): Record<string, string> => {
|
||||||
if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) {
|
if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) {
|
||||||
res.image = youtubeThumbnailMatch[1] + 'default.jpg'
|
res.image = youtubeThumbnailMatch[1] + 'default.jpg'
|
||||||
}
|
}
|
||||||
|
if (!res.image && youtubeAvatarMatch && youtubeAvatarMatch.length >= 1) {
|
||||||
|
res.image = youtubeAvatarMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
return res
|
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 {AppBskyEmbedImages} from '@atproto/api'
|
||||||
import {NotificationsViewItemModel} from '../../state/models/notifications-view'
|
import {RootStoreModel} from 'state/models/root-store'
|
||||||
import {enforceLen} from '../../lib/strings'
|
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(
|
export function displayNotification(
|
||||||
title: string,
|
title: string,
|
||||||
|
@ -39,7 +58,8 @@ export function displayNotificationFromModel(
|
||||||
title = `${author} replied to your post`
|
title = `${author} replied to your post`
|
||||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||||
} else if (notif.isFollow) {
|
} else if (notif.isFollow) {
|
||||||
title = `${author} followed you`
|
title = 'New follower!'
|
||||||
|
body = `${author} has followed you`
|
||||||
} else {
|
} else {
|
||||||
return
|
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'
|
import {Theme, TypographyVariant} from './ThemeContext'
|
||||||
|
|
||||||
// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
|
// 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,
|
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,
|
link: colors.blue3,
|
||||||
border: '#f0e9e9',
|
border: '#f0e9e9',
|
||||||
borderDark: '#e0d9d9',
|
borderDark: '#e0d9d9',
|
||||||
icon: colors.gray3,
|
icon: colors.gray4,
|
||||||
|
|
||||||
// non-standard
|
// non-standard
|
||||||
textVeryLight: colors.gray4,
|
textVeryLight: colors.gray4,
|
||||||
|
@ -208,11 +208,16 @@ export const defaultTheme: Theme = {
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
},
|
},
|
||||||
|
|
||||||
'title-xl': {
|
'title-2xl': {
|
||||||
fontSize: 34,
|
fontSize: 34,
|
||||||
letterSpacing: 0.25,
|
letterSpacing: 0.25,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
'title-xl': {
|
||||||
|
fontSize: 28,
|
||||||
|
letterSpacing: 0.25,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
'title-lg': {
|
'title-lg': {
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
@ -237,6 +242,11 @@ export const defaultTheme: Theme = {
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
|
'button-lg': {
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: 18,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
button: {
|
button: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
@ -263,7 +273,7 @@ export const darkTheme: Theme = {
|
||||||
link: colors.blue3,
|
link: colors.blue3,
|
||||||
border: colors.gray6,
|
border: colors.gray6,
|
||||||
borderDark: colors.gray5,
|
borderDark: colors.gray5,
|
||||||
icon: colors.gray5,
|
icon: colors.gray4,
|
||||||
|
|
||||||
// non-standard
|
// non-standard
|
||||||
textVeryLight: colors.gray4,
|
textVeryLight: colors.gray4,
|
|
@ -1,9 +1,9 @@
|
||||||
import {autorun} from 'mobx'
|
import {autorun} from 'mobx'
|
||||||
import {Platform} from 'react-native'
|
import {AppState, Platform} from 'react-native'
|
||||||
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
|
import {AtpAgent} from '@atproto/api'
|
||||||
import {RootStoreModel} from './models/root-store'
|
import {RootStoreModel} from './models/root-store'
|
||||||
import * as apiPolyfill from './lib/api-polyfill'
|
import * as apiPolyfill from 'lib/api/api-polyfill'
|
||||||
import * as storage from './lib/storage'
|
import * as storage from 'lib/storage'
|
||||||
|
|
||||||
export const LOCAL_DEV_SERVICE =
|
export const LOCAL_DEV_SERVICE =
|
||||||
Platform.OS === 'ios' ? 'http://localhost:2583' : 'http://10.0.2.2:2583'
|
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()
|
apiPolyfill.doPolyfill()
|
||||||
|
|
||||||
const api = AtpApi.service(serviceUri) as SessionServiceClient
|
rootStore = new RootStoreModel(new AtpAgent({service: serviceUri}))
|
||||||
rootStore = new RootStoreModel(api)
|
|
||||||
try {
|
try {
|
||||||
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
|
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
|
||||||
rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
|
rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
|
||||||
|
@ -28,25 +27,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
rootStore.log.error('Failed to load state from storage', e)
|
rootStore.log.error('Failed to load state from storage', e)
|
||||||
}
|
}
|
||||||
|
rootStore.attemptSessionResumption()
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// track changes & save to storage
|
// track changes & save to storage
|
||||||
autorun(() => {
|
autorun(() => {
|
||||||
|
@ -56,7 +37,14 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
|
||||||
|
|
||||||
// periodic state fetch
|
// periodic state fetch
|
||||||
setInterval(() => {
|
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)
|
}, STATE_FETCH_INTERVAL)
|
||||||
|
|
||||||
return rootStore
|
return rootStore
|
||||||
|
|
|
@ -5,13 +5,16 @@ import {
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
import AwaitLock from 'await-lock'
|
||||||
|
import {bundleAsync} from 'lib/async/bundle'
|
||||||
type FeedViewPost = AppBskyFeedFeedViewPost.Main
|
type FeedViewPost = AppBskyFeedFeedViewPost.Main
|
||||||
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
|
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
|
||||||
type PostView = AppBskyFeedPost.View
|
type PostView = AppBskyFeedPost.View
|
||||||
import {AtUri} from '../../third-party/uri'
|
import {AtUri} from '../../third-party/uri'
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
import * as apilib from '../lib/api'
|
import * as apilib from 'lib/api/index'
|
||||||
import {cleanError} from '../../lib/strings'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
|
import {RichText} from 'lib/strings/rich-text'
|
||||||
|
|
||||||
const PAGE_SIZE = 30
|
const PAGE_SIZE = 30
|
||||||
|
|
||||||
|
@ -37,6 +40,7 @@ export class FeedItemModel {
|
||||||
reply?: FeedViewPost['reply']
|
reply?: FeedViewPost['reply']
|
||||||
replyParent?: FeedItemModel
|
replyParent?: FeedItemModel
|
||||||
reason?: FeedViewPost['reason']
|
reason?: FeedViewPost['reason']
|
||||||
|
richText?: RichText
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public rootStore: RootStoreModel,
|
public rootStore: RootStoreModel,
|
||||||
|
@ -49,6 +53,11 @@ export class FeedItemModel {
|
||||||
const valid = AppBskyFeedPost.validateRecord(this.post.record)
|
const valid = AppBskyFeedPost.validateRecord(this.post.record)
|
||||||
if (valid.success) {
|
if (valid.success) {
|
||||||
this.postRecord = this.post.record
|
this.postRecord = this.post.record
|
||||||
|
this.richText = new RichText(
|
||||||
|
this.postRecord.text,
|
||||||
|
this.postRecord.entities,
|
||||||
|
{cleanNewlines: true},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
rootStore.log.warn(
|
rootStore.log.warn(
|
||||||
'Received an invalid app.bsky.feed.post record',
|
'Received an invalid app.bsky.feed.post record',
|
||||||
|
@ -187,10 +196,9 @@ export class FeedModel {
|
||||||
hasMore = true
|
hasMore = true
|
||||||
loadMoreCursor: string | undefined
|
loadMoreCursor: string | undefined
|
||||||
pollCursor: string | undefined
|
pollCursor: string | undefined
|
||||||
_loadPromise: Promise<void> | undefined
|
|
||||||
_loadMorePromise: Promise<void> | undefined
|
// used to linearize async modifications to state
|
||||||
_loadLatestPromise: Promise<void> | undefined
|
private lock = new AwaitLock()
|
||||||
_updatePromise: Promise<void> | undefined
|
|
||||||
|
|
||||||
// data
|
// data
|
||||||
feed: FeedItemModel[] = []
|
feed: FeedItemModel[] = []
|
||||||
|
@ -206,10 +214,6 @@ export class FeedModel {
|
||||||
rootStore: false,
|
rootStore: false,
|
||||||
params: false,
|
params: false,
|
||||||
loadMoreCursor: false,
|
loadMoreCursor: false,
|
||||||
_loadPromise: false,
|
|
||||||
_loadMorePromise: false,
|
|
||||||
_loadLatestPromise: false,
|
|
||||||
_updatePromise: false,
|
|
||||||
},
|
},
|
||||||
{autoBind: true},
|
{autoBind: true},
|
||||||
)
|
)
|
||||||
|
@ -229,13 +233,22 @@ export class FeedModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
get nonReplyFeed() {
|
get nonReplyFeed() {
|
||||||
return this.feed.filter(
|
const nonReplyFeed = this.feed.filter(item => {
|
||||||
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
|
!item.reply || // not a reply
|
||||||
|
isRepost ||
|
||||||
((item._isThreadParent || // but allow if it's a thread by the user
|
((item._isThreadParent || // but allow if it's a thread by the user
|
||||||
item._isThreadChild) &&
|
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) {
|
setHasNewLatest(v: boolean) {
|
||||||
|
@ -245,22 +258,45 @@ export class FeedModel {
|
||||||
// public api
|
// 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
|
* Load for first render
|
||||||
*/
|
*/
|
||||||
async setup(isRefreshing = false) {
|
setup = bundleAsync(async (isRefreshing: boolean = false) => {
|
||||||
|
this.rootStore.log.debug('FeedModel:setup', {isRefreshing})
|
||||||
if (isRefreshing) {
|
if (isRefreshing) {
|
||||||
this.isRefreshing = true // set optimistically for UI
|
this.isRefreshing = true // set optimistically for UI
|
||||||
}
|
}
|
||||||
if (this._loadPromise) {
|
await this.lock.acquireAsync()
|
||||||
return this._loadPromise
|
try {
|
||||||
|
this.setHasNewLatest(false)
|
||||||
|
this._xLoading(isRefreshing)
|
||||||
|
try {
|
||||||
|
const res = await this._getFeed({limit: PAGE_SIZE})
|
||||||
|
await this._replaceAll(res)
|
||||||
|
this._xIdle()
|
||||||
|
} catch (e: any) {
|
||||||
|
this._xIdle(e)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
}
|
}
|
||||||
await this._pendingWork()
|
})
|
||||||
this.setHasNewLatest(false)
|
|
||||||
this._loadPromise = this._initialLoad(isRefreshing)
|
|
||||||
await this._loadPromise
|
|
||||||
this._loadPromise = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register any event listeners. Returns a cleanup function.
|
* Register any event listeners. Returns a cleanup function.
|
||||||
|
@ -280,42 +316,93 @@ export class FeedModel {
|
||||||
/**
|
/**
|
||||||
* Load more posts to the end of the feed
|
* Load more posts to the end of the feed
|
||||||
*/
|
*/
|
||||||
async loadMore() {
|
loadMore = bundleAsync(async () => {
|
||||||
if (this._loadMorePromise) {
|
await this.lock.acquireAsync()
|
||||||
return this._loadMorePromise
|
try {
|
||||||
|
if (!this.hasMore || this.hasError) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._xLoading()
|
||||||
|
try {
|
||||||
|
const res = await this._getFeed({
|
||||||
|
before: this.loadMoreCursor,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
await this._appendAll(res)
|
||||||
|
this._xIdle()
|
||||||
|
} catch (e: any) {
|
||||||
|
this._xIdle() // don't bubble the error to the user
|
||||||
|
this.rootStore.log.error('FeedView: Failed to load more', {
|
||||||
|
params: this.params,
|
||||||
|
e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
}
|
}
|
||||||
await this._pendingWork()
|
})
|
||||||
this._loadMorePromise = this._loadMore()
|
|
||||||
await this._loadMorePromise
|
|
||||||
this._loadMorePromise = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load more posts to the start of the feed
|
* Load more posts to the start of the feed
|
||||||
*/
|
*/
|
||||||
async loadLatest() {
|
loadLatest = bundleAsync(async () => {
|
||||||
if (this._loadLatestPromise) {
|
await this.lock.acquireAsync()
|
||||||
return this._loadLatestPromise
|
try {
|
||||||
|
this.setHasNewLatest(false)
|
||||||
|
this._xLoading()
|
||||||
|
try {
|
||||||
|
const res = await this._getFeed({limit: PAGE_SIZE})
|
||||||
|
await this._prependAll(res)
|
||||||
|
this._xIdle()
|
||||||
|
} catch (e: any) {
|
||||||
|
this._xIdle() // don't bubble the error to the user
|
||||||
|
this.rootStore.log.error('FeedView: Failed to load latest', {
|
||||||
|
params: this.params,
|
||||||
|
e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
}
|
}
|
||||||
await this._pendingWork()
|
})
|
||||||
this.setHasNewLatest(false)
|
|
||||||
this._loadLatestPromise = this._loadLatest()
|
|
||||||
await this._loadLatestPromise
|
|
||||||
this._loadLatestPromise = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update content in-place
|
* Update content in-place
|
||||||
*/
|
*/
|
||||||
async update() {
|
update = bundleAsync(async () => {
|
||||||
if (this._updatePromise) {
|
await this.lock.acquireAsync()
|
||||||
return this._updatePromise
|
try {
|
||||||
|
if (!this.feed.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._xLoading()
|
||||||
|
let numToFetch = this.feed.length
|
||||||
|
let cursor
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const res: GetTimeline.Response = await this._getFeed({
|
||||||
|
before: cursor,
|
||||||
|
limit: Math.min(numToFetch, 100),
|
||||||
|
})
|
||||||
|
if (res.data.feed.length === 0) {
|
||||||
|
break // sanity check
|
||||||
|
}
|
||||||
|
this._updateAll(res)
|
||||||
|
numToFetch -= res.data.feed.length
|
||||||
|
cursor = res.data.cursor
|
||||||
|
} while (cursor && numToFetch > 0)
|
||||||
|
this._xIdle()
|
||||||
|
} catch (e: any) {
|
||||||
|
this._xIdle() // don't bubble the error to the user
|
||||||
|
this.rootStore.log.error('FeedView: Failed to update', {
|
||||||
|
params: this.params,
|
||||||
|
e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.lock.release()
|
||||||
}
|
}
|
||||||
await this._pendingWork()
|
})
|
||||||
this._updatePromise = this._update()
|
|
||||||
await this._updatePromise
|
|
||||||
this._updatePromise = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if new posts are available
|
* Check if new posts are available
|
||||||
|
@ -324,17 +411,18 @@ export class FeedModel {
|
||||||
if (this.hasNewLatest) {
|
if (this.hasNewLatest) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await this._pendingWork()
|
|
||||||
const res = await this._getFeed({limit: 1})
|
const res = await this._getFeed({limit: 1})
|
||||||
const currentLatestUri = this.pollCursor
|
const currentLatestUri = this.pollCursor
|
||||||
const receivedLatestUri = res.data.feed[0]
|
const item = res.data.feed[0]
|
||||||
? res.data.feed[0].post.uri
|
if (!item) {
|
||||||
: undefined
|
return
|
||||||
const hasNewLatest = Boolean(
|
}
|
||||||
receivedLatestUri &&
|
if (AppBskyFeedFeedViewPost.isReasonRepost(item.reason)) {
|
||||||
(this.feed.length === 0 || receivedLatestUri !== currentLatestUri),
|
if (item.reason.by.did === this.rootStore.me.did) {
|
||||||
)
|
return // ignore reposts by the user
|
||||||
this.setHasNewLatest(hasNewLatest)
|
}
|
||||||
|
}
|
||||||
|
this.setHasNewLatest(item.post.uri !== currentLatestUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -363,95 +451,15 @@ export class FeedModel {
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
this.isRefreshing = false
|
this.isRefreshing = false
|
||||||
this.hasLoaded = true
|
this.hasLoaded = true
|
||||||
this.error = err ? cleanError(err.toString()) : ''
|
this.error = cleanError(err)
|
||||||
if (err) {
|
if (err) {
|
||||||
this.rootStore.log.error('Posts feed request failed', 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(
|
private async _replaceAll(
|
||||||
res: GetTimeline.Response | GetAuthorFeed.Response,
|
res: GetTimeline.Response | GetAuthorFeed.Response,
|
||||||
) {
|
) {
|
||||||
|
@ -570,11 +578,46 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
|
||||||
reorg.unshift(item)
|
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 activeSlice = -1
|
||||||
let threadSlices: Slice[] = []
|
let threadSlices: Slice[] = []
|
||||||
for (let i = 0; i < reorg.length; i++) {
|
for (let i = 0; i < feed.length; i++) {
|
||||||
const item = reorg[i] as FeedViewPostWithThreadMeta
|
const item = feed[i] as FeedViewPostWithThreadMeta
|
||||||
if (activeSlice === -1) {
|
if (activeSlice === -1) {
|
||||||
if (item._isThreadParent) {
|
if (item._isThreadParent) {
|
||||||
activeSlice = i
|
activeSlice = i
|
||||||
|
@ -591,39 +634,9 @@ function preprocessFeed(feed: FeedViewPost[]): FeedViewPostWithThreadMeta[] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeSlice !== -1) {
|
if (activeSlice !== -1) {
|
||||||
threadSlices.push({index: activeSlice, length: reorg.length - activeSlice})
|
threadSlices.push({index: activeSlice, length: feed.length - activeSlice})
|
||||||
}
|
}
|
||||||
|
return threadSlices
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WARNING: mutates `feed`
|
// 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 {makeAutoObservable} from 'mobx'
|
||||||
import {LRUMap} from 'lru_map'
|
import {LRUMap} from 'lru_map'
|
||||||
import {RootStoreModel} from './root-store'
|
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
|
type CacheValue = Promise<LinkMeta> | LinkMeta
|
||||||
export class LinkMetasViewModel {
|
export class LinkMetasViewModel {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import {makeAutoObservable} from 'mobx'
|
import {makeAutoObservable} from 'mobx'
|
||||||
import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc'
|
import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc'
|
||||||
import {isObj, hasProp} from '../lib/type-guards'
|
import {isObj, hasProp} from 'lib/type-guards'
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
id: string
|
id: string
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import notifee from '@notifee/react-native'
|
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
import {FeedModel} from './feed-view'
|
import {FeedModel} from './feed-view'
|
||||||
import {NotificationsViewModel} from './notifications-view'
|
import {NotificationsViewModel} from './notifications-view'
|
||||||
import {isObj, hasProp} from '../lib/type-guards'
|
import {MyFollowsModel} from './my-follows'
|
||||||
import {displayNotificationFromModel} from '../../view/lib/notifee'
|
import {isObj, hasProp} from 'lib/type-guards'
|
||||||
|
|
||||||
export class MeModel {
|
export class MeModel {
|
||||||
did: string = ''
|
did: string = ''
|
||||||
|
@ -12,9 +11,9 @@ export class MeModel {
|
||||||
displayName: string = ''
|
displayName: string = ''
|
||||||
description: string = ''
|
description: string = ''
|
||||||
avatar: string = ''
|
avatar: string = ''
|
||||||
notificationCount: number = 0
|
|
||||||
mainFeed: FeedModel
|
mainFeed: FeedModel
|
||||||
notifications: NotificationsViewModel
|
notifications: NotificationsViewModel
|
||||||
|
follows: MyFollowsModel
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
|
@ -26,15 +25,17 @@ export class MeModel {
|
||||||
algorithm: 'reverse-chronological',
|
algorithm: 'reverse-chronological',
|
||||||
})
|
})
|
||||||
this.notifications = new NotificationsViewModel(this.rootStore, {})
|
this.notifications = new NotificationsViewModel(this.rootStore, {})
|
||||||
|
this.follows = new MyFollowsModel(this.rootStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
|
this.mainFeed.clear()
|
||||||
|
this.notifications.clear()
|
||||||
this.did = ''
|
this.did = ''
|
||||||
this.handle = ''
|
this.handle = ''
|
||||||
this.displayName = ''
|
this.displayName = ''
|
||||||
this.description = ''
|
this.description = ''
|
||||||
this.avatar = ''
|
this.avatar = ''
|
||||||
this.notificationCount = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize(): unknown {
|
serialize(): unknown {
|
||||||
|
@ -77,9 +78,10 @@ export class MeModel {
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
const sess = this.rootStore.session
|
const sess = this.rootStore.session
|
||||||
if (sess.hasSession && sess.data) {
|
this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession})
|
||||||
this.did = sess.data.did || ''
|
if (sess.hasSession) {
|
||||||
this.handle = sess.data.handle
|
this.did = sess.currentSession?.did || ''
|
||||||
|
this.handle = sess.currentSession?.handle || ''
|
||||||
const profile = await this.rootStore.api.app.bsky.actor.getProfile({
|
const profile = await this.rootStore.api.app.bsky.actor.getProfile({
|
||||||
actor: this.did,
|
actor: this.did,
|
||||||
})
|
})
|
||||||
|
@ -94,10 +96,6 @@ export class MeModel {
|
||||||
this.avatar = ''
|
this.avatar = ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.mainFeed = new FeedModel(this.rootStore, 'home', {
|
|
||||||
algorithm: 'reverse-chronological',
|
|
||||||
})
|
|
||||||
this.notifications = new NotificationsViewModel(this.rootStore, {})
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.mainFeed.setup().catch(e => {
|
this.mainFeed.setup().catch(e => {
|
||||||
this.rootStore.log.error('Failed to setup main feed model', 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.notifications.setup().catch(e => {
|
||||||
this.rootStore.log.error('Failed to setup notifications model', 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)
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
this.rootStore.emitSessionLoaded()
|
||||||
// request notifications permission once the user has logged in
|
|
||||||
notifee.requestPermission()
|
|
||||||
} else {
|
} else {
|
||||||
this.clear()
|
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