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>
This commit is contained in:
Paul Frazee 2023-02-22 14:23:57 -06:00 committed by GitHub
parent 7916b26aad
commit f28334739b
242 changed files with 8400 additions and 7454 deletions

View file

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

View file

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

74
src/lib/analytics.tsx Normal file
View file

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

16
src/lib/analytics.web.tsx Normal file
View file

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

View file

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

View file

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

4
src/lib/app-info.ts Normal file
View file

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

3
src/lib/app-info.web.ts Normal file
View file

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

5
src/lib/assets.native.ts Normal file
View file

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

10
src/lib/assets.ts Normal file
View file

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

24
src/lib/async/bundle.ts Normal file
View file

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

View file

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

65
src/lib/constants.ts Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

61
src/lib/permissions.ts Normal file
View file

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

View file

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

View file

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

23
src/lib/strings/errors.ts Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

29
src/lib/strings/time.ts Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,7 @@
import {RootStoreModel} from './root-store'
import {makeAutoObservable} from 'mobx'
import {TABS_ENABLED} from '../../build-flags'
import {TABS_ENABLED} from 'lib/build-flags'
import {segmentClient} from 'lib/analytics'
let __id = 0
function genId() {
@ -11,13 +13,20 @@ function genId() {
// we've since decided to pause that idea and do something more traditional
// until we're fully sure what that is, the tabs are being repurposed into a fixed topology
// - Tab 0: The "Default" tab
// - Tab 1: The "Notifications" tab
// - Tab 1: The "Search" tab
// - Tab 2: The "Notifications" tab
// These tabs always retain the first item in their history.
// The default tab is used for basically everything except notifications.
// -prf
export enum TabPurpose {
Default = 0,
Notifs = 1,
Search = 1,
Notifs = 2,
}
export const TabPurposeMainPath: Record<TabPurpose, string> = {
[TabPurpose.Default]: '/',
[TabPurpose.Search]: '/search',
[TabPurpose.Notifs]: '/notifications',
}
interface HistoryItem {
@ -36,11 +45,9 @@ export class NavigationTabModel {
isNewTab = false
constructor(public fixedTabPurpose: TabPurpose) {
if (fixedTabPurpose === TabPurpose.Notifs) {
this.history = [{url: '/notifications', ts: Date.now(), id: genId()}]
} else {
this.history = [{url: '/', ts: Date.now(), id: genId()}]
}
this.history = [
{url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()},
]
makeAutoObservable(this, {
serialize: false,
hydrate: false,
@ -96,6 +103,13 @@ export class NavigationTabModel {
// =
navigate(url: string, title?: string) {
try {
const path = url.split('/')[1]
segmentClient.track('Navigation', {
path,
})
} catch (error) {}
if (this.current?.url === url) {
this.refresh()
} else {
@ -104,8 +118,7 @@ export class NavigationTabModel {
}
// TEMP ensure the tab has its purpose's main view -prf
if (this.history.length < 1) {
const fixedUrl =
this.fixedTabPurpose === TabPurpose.Notifs ? '/notifications' : '/'
const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose]
this.history.push({url: fixedUrl, ts: Date.now(), id: genId()})
}
this.history.push({url, title, ts: Date.now(), id: genId()})
@ -211,12 +224,14 @@ export class NavigationTabModel {
export class NavigationModel {
tabs: NavigationTabModel[] = [
new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Search),
new NavigationTabModel(TabPurpose.Notifs),
]
tabIndex = 0
constructor() {
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
rootStore: false,
serialize: false,
hydrate: false,
})
@ -225,6 +240,7 @@ export class NavigationModel {
clear() {
this.tabs = [
new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Search),
new NavigationTabModel(TabPurpose.Notifs),
]
this.tabIndex = 0
@ -249,6 +265,7 @@ export class NavigationModel {
// =
navigate(url: string, title?: string) {
this.rootStore.emitNavigation()
this.tab.navigate(url, title)
}
@ -286,10 +303,16 @@ export class NavigationModel {
// fixed tab helper function
// -prf
switchTo(purpose: TabPurpose, reset: boolean) {
if (purpose === TabPurpose.Notifs) {
this.tabIndex = 1
} else {
this.tabIndex = 0
this.rootStore.emitNavigation()
switch (purpose) {
case TabPurpose.Notifs:
this.tabIndex = 2
break
case TabPurpose.Search:
this.tabIndex = 1
break
default:
this.tabIndex = 0
}
if (reset) {
this.tab.fixedTabReset()

View file

@ -8,11 +8,13 @@ import {
AppBskyGraphAssertion,
AppBskyGraphFollow,
} from '@atproto/api'
import AwaitLock from 'await-lock'
import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from './root-store'
import {PostThreadViewModel} from './post-thread-view'
import {cleanError} from '../../lib/strings'
import {cleanError} from 'lib/strings/errors'
const UNGROUPABLE_REASONS = ['assertion']
const GROUPABLE_REASONS = ['vote', 'repost', 'follow']
const PAGE_SIZE = 30
const MS_1HR = 1e3 * 60 * 60
const MS_2DAY = MS_1HR * 48
@ -190,15 +192,16 @@ export class NotificationsViewModel {
params: ListNotifications.QueryParams
hasMore = true
loadMoreCursor?: string
_loadPromise: Promise<void> | undefined
_loadMorePromise: Promise<void> | undefined
_updatePromise: Promise<void> | undefined
// used to linearize async modifications to state
private lock = new AwaitLock()
// data
notifications: NotificationsViewItemModel[] = []
unreadCount = 0
// this is used to help trigger push notifications
mostRecentNotification: NotificationsViewItemModel | undefined
mostRecentNotificationUri: string | undefined
constructor(
public rootStore: RootStoreModel,
@ -209,10 +212,7 @@ export class NotificationsViewModel {
{
rootStore: false,
params: false,
mostRecentNotification: false,
_loadPromise: false,
_loadMorePromise: false,
_updatePromise: false,
mostRecentNotificationUri: false,
},
{autoBind: true},
)
@ -234,21 +234,48 @@ export class NotificationsViewModel {
// public api
// =
/**
* Nuke all data
*/
clear() {
this.rootStore.log.debug('NotificationsModel:clear')
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = false
this.error = ''
this.hasMore = true
this.loadMoreCursor = undefined
this.notifications = []
this.unreadCount = 0
this.rootStore.emitUnreadNotifications(0)
this.mostRecentNotificationUri = undefined
}
/**
* Load for first render
*/
async setup(isRefreshing = false) {
setup = bundleAsync(async (isRefreshing: boolean = false) => {
this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing})
if (isRefreshing) {
this.isRefreshing = true // set optimistically for UI
}
if (this._loadPromise) {
return this._loadPromise
await this.lock.acquireAsync()
try {
this._xLoading(isRefreshing)
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
})
const res = await this.rootStore.api.app.bsky.notification.list(params)
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
} finally {
this.lock.release()
}
await this._pendingWork()
this._loadPromise = this._initialLoad(isRefreshing)
await this._loadPromise
this._loadPromise = undefined
}
})
/**
* Reset and load
@ -260,59 +287,148 @@ export class NotificationsViewModel {
/**
* Load more posts to the end of the notifications
*/
async loadMore() {
if (this._loadMorePromise) {
return this._loadMorePromise
loadMore = bundleAsync(async () => {
if (!this.hasMore) {
return
}
await this._pendingWork()
this._loadMorePromise = this._loadMore()
await this._loadMorePromise
this._loadMorePromise = undefined
}
this.lock.acquireAsync()
try {
this._xLoading()
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
before: this.loadMoreCursor,
})
const res = await this.rootStore.api.app.bsky.notification.list(params)
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle() // don't bubble the error to the user
this.rootStore.log.error('NotificationsView: Failed to load more', {
params: this.params,
e,
})
}
} finally {
this.lock.release()
}
})
/**
* Load more posts at the start of the notifications
*/
loadLatest = bundleAsync(async () => {
if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) {
return this.refresh()
}
this.lock.acquireAsync()
try {
this._xLoading()
try {
const res = await this.rootStore.api.app.bsky.notification.list({
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('NotificationsView: Failed to load latest', {
params: this.params,
e,
})
}
} finally {
this.lock.release()
}
})
/**
* Update content in-place
*/
async update() {
if (this._updatePromise) {
return this._updatePromise
update = bundleAsync(async () => {
await this.lock.acquireAsync()
try {
if (!this.notifications.length) {
return
}
this._xLoading()
let numToFetch = this.notifications.length
let cursor
try {
do {
const res: ListNotifications.Response =
await this.rootStore.api.app.bsky.notification.list({
before: cursor,
limit: Math.min(numToFetch, 100),
})
if (res.data.notifications.length === 0) {
break // sanity check
}
this._updateAll(res)
numToFetch -= res.data.notifications.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('NotificationsView: Failed to update', {
params: this.params,
e,
})
}
} finally {
this.lock.release()
}
await this._pendingWork()
this._updatePromise = this._update()
await this._updatePromise
this._updatePromise = undefined
}
})
// unread notification apis
// =
/**
* Get the current number of unread notifications
* returns true if the number changed
*/
loadUnreadCount = bundleAsync(async () => {
const old = this.unreadCount
const res = await this.rootStore.api.app.bsky.notification.getCount()
runInAction(() => {
this.unreadCount = res.data.count
})
this.rootStore.emitUnreadNotifications(this.unreadCount)
return this.unreadCount !== old
})
/**
* Update read/unread state
*/
async updateReadState() {
async markAllRead() {
try {
this.unreadCount = 0
this.rootStore.emitUnreadNotifications(0)
await this.rootStore.api.app.bsky.notification.updateSeen({
seenAt: new Date().toISOString(),
})
this.rootStore.me.clearNotificationCount()
} catch (e: any) {
this.rootStore.log.warn('Failed to update notifications read state', e)
}
}
async getNewMostRecent(): Promise<NotificationsViewItemModel | undefined> {
let old = this.mostRecentNotification
const res = await this.rootStore.api.app.bsky.notification.list({limit: 1})
if (
!res.data.notifications[0] ||
old?.uri === res.data.notifications[0].uri
) {
let old = this.mostRecentNotificationUri
const res = await this.rootStore.api.app.bsky.notification.list({
limit: 1,
})
if (!res.data.notifications[0] || old === res.data.notifications[0].uri) {
return
}
this.mostRecentNotification = new NotificationsViewItemModel(
this.mostRecentNotificationUri = res.data.notifications[0].uri
const notif = new NotificationsViewItemModel(
this.rootStore,
'mostRecent',
res.data.notifications[0],
)
await this.mostRecentNotification.fetchAdditionalData()
return this.mostRecentNotification
await notif.fetchAdditionalData()
return notif
}
// state transitions
@ -329,93 +445,17 @@ export class NotificationsViewModel {
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
this.error = err ? cleanError(err) : ''
if (err) {
this.rootStore.log.error('Failed to fetch notifications', err)
}
}
// loader functions
// helper functions
// =
private async _pendingWork() {
if (this._loadPromise) {
await this._loadPromise
}
if (this._loadMorePromise) {
await this._loadMorePromise
}
if (this._updatePromise) {
await this._updatePromise
}
}
private async _initialLoad(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
})
const res = await this.rootStore.api.app.bsky.notification.list(params)
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
private async _loadMore() {
if (!this.hasMore) {
return
}
this._xLoading()
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
before: this.loadMoreCursor,
})
const res = await this.rootStore.api.app.bsky.notification.list(params)
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
private async _update() {
if (!this.notifications.length) {
return
}
this._xLoading()
let numToFetch = this.notifications.length
let cursor
try {
do {
const res: ListNotifications.Response =
await this.rootStore.api.app.bsky.notification.list({
before: cursor,
limit: Math.min(numToFetch, 100),
})
if (res.data.notifications.length === 0) {
break // sanity check
}
this._updateAll(res)
numToFetch -= res.data.notifications.length
cursor = res.data.cursor
} while (cursor && numToFetch > 0)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
private async _replaceAll(res: ListNotifications.Response) {
if (res.data.notifications[0]) {
this.mostRecentNotification = new NotificationsViewItemModel(
this.rootStore,
'mostRecent',
res.data.notifications[0],
)
this.mostRecentNotificationUri = res.data.notifications[0].uri
}
return this._appendAll(res, true)
}
@ -451,14 +491,40 @@ export class NotificationsViewModel {
})
}
private async _prependAll(res: ListNotifications.Response) {
const promises = []
const itemModels: NotificationsViewItemModel[] = []
const dedupedNotifs = res.data.notifications.filter(
n1 =>
!this.notifications.find(
n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)),
),
)
for (const item of groupNotifications(dedupedNotifs)) {
const itemModel = new NotificationsViewItemModel(
this.rootStore,
`item-${_idCounter++}`,
item,
)
if (itemModel.needsAdditionalData) {
promises.push(itemModel.fetchAdditionalData())
}
itemModels.push(itemModel)
}
await Promise.all(promises).catch(e => {
this.rootStore.log.error(
'Uncaught failure during notifications-view _prependAll()',
e,
)
})
runInAction(() => {
this.notifications = itemModels.concat(this.notifications)
})
}
private _updateAll(res: ListNotifications.Response) {
for (const item of res.data.notifications) {
const existingItem = this.notifications.find(
// this find function has a key subtlety- the indexedAt comparison
// the reason for this is reposts: they set the URI of the original post, not of the repost record
// the indexedAt time will be for the repost however, so we use that to help us
item2 => item.uri === item2.uri && item.indexedAt === item2.indexedAt,
)
const existingItem = this.notifications.find(item2 => isEq(item, item2))
if (existingItem) {
existingItem.copy(item, true)
}
@ -473,7 +539,7 @@ function groupNotifications(
for (const item of items) {
const ts = +new Date(item.indexedAt)
let grouped = false
if (!UNGROUPABLE_REASONS.includes(item.reason)) {
if (GROUPABLE_REASONS.includes(item.reason)) {
for (const item2 of items2) {
const ts2 = +new Date(item2.indexedAt)
if (
@ -495,3 +561,11 @@ function groupNotifications(
}
return items2
}
type N = ListNotifications.Notification | NotificationsViewItemModel
function isEq(a: N, b: N) {
// this function has a key subtlety- the indexedAt comparison
// the reason for this is reposts: they set the URI of the original post, not of the repost record
// the indexedAt time will be for the repost however, so we use that to help us
return a.uri === b.uri && a.indexedAt === b.indexedAt
}

View file

@ -1,5 +1,5 @@
import {makeAutoObservable} from 'mobx'
import {isObj, hasProp} from '../lib/type-guards'
import {isObj, hasProp} from 'lib/type-guards'
export const OnboardStage = {
Explainers: 'explainers',

View file

@ -5,7 +5,9 @@ import {
} from '@atproto/api'
import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {RichText} from 'lib/strings/rich-text'
function* reactKeyGenerator(): Generator<string> {
let counter = 0
@ -26,6 +28,7 @@ export class PostThreadViewPostModel {
postRecord?: FeedPost.Record
parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost
replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[]
richText?: RichText
constructor(
public rootStore: RootStoreModel,
@ -38,6 +41,11 @@ export class PostThreadViewPostModel {
const valid = FeedPost.validateRecord(this.post.record)
if (valid.success) {
this.postRecord = this.post.record
this.richText = new RichText(
this.postRecord.text,
this.postRecord.entities,
{cleanNewlines: true},
)
} else {
rootStore.log.warn(
'Received an invalid app.bsky.feed.post record',
@ -276,7 +284,7 @@ export class PostThreadViewModel {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err ? err.toString() : ''
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch post thread', err)
}
@ -290,7 +298,7 @@ export class PostThreadViewModel {
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
try {
urip.host = await this.rootStore.resolveName(urip.host)
urip.host = await apilib.resolveName(this.rootStore, urip.host)
} catch (e: any) {
this.error = e.toString()
}
@ -314,7 +322,7 @@ export class PostThreadViewModel {
}
private _replaceAll(res: GetPostThread.Response) {
// sortThread(res.data.thread) TODO needed?
sortThread(res.data.thread)
const keyGen = reactKeyGenerator()
const thread = new PostThreadViewPostModel(
this.rootStore,
@ -330,36 +338,37 @@ export class PostThreadViewModel {
}
}
/*
TODO needed?
type MaybePost =
| GetPostThread.ThreadViewPost
| GetPostThread.NotFoundPost
| {[k: string]: unknown; $type: string}
function sortThread(post: MaybePost) {
if (post.notFound) {
return
}
post = post as GetPostThread.Post
post = post as GetPostThread.ThreadViewPost
if (post.replies) {
post.replies.sort((a: MaybePost, b: MaybePost) => {
post = post as GetPostThread.Post
post = post as GetPostThread.ThreadViewPost
if (a.notFound) {
return 1
}
if (b.notFound) {
return -1
}
a = a as GetPostThread.Post
b = b as GetPostThread.Post
const aIsByOp = a.author.did === post.author.did
const bIsByOp = b.author.did === post.author.did
a = a as GetPostThread.ThreadViewPost
b = b as GetPostThread.ThreadViewPost
const aIsByOp = a.post.author.did === post.post.author.did
const bIsByOp = b.post.author.did === post.post.author.did
if (aIsByOp && bIsByOp) {
return a.indexedAt.localeCompare(b.indexedAt) // oldest
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
} else if (aIsByOp) {
return -1 // op's own reply
} else if (bIsByOp) {
return 1 // op's own reply
}
return b.indexedAt.localeCompare(a.indexedAt) // newest
return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
})
post.replies.forEach(reply => sortThread(reply))
}
}
*/

View file

@ -2,7 +2,7 @@ import {makeAutoObservable} from 'mobx'
import {AppBskyFeedPost as Post} from '@atproto/api'
import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from './root-store'
import {cleanError} from '../../lib/strings'
import {cleanError} from 'lib/strings/errors'
type RemoveIndex<T> = {
[P in keyof T as string extends P
@ -67,7 +67,6 @@ export class PostModel implements RemoveIndex<Post.Record> {
this.isLoading = false
this.hasLoaded = true
this.error = cleanError(err)
this.error = err ? cleanError(err) : ''
if (err) {
this.rootStore.log.error('Failed to fetch post', err)
}

View file

@ -1,22 +1,23 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {Image as PickedImage} from '../../view/com/util/images/image-crop-picker/ImageCropPicker'
import {PickedMedia} from 'view/com/util/images/image-crop-picker/ImageCropPicker'
import {
AppBskyActorGetProfile as GetProfile,
AppBskyActorProfile as Profile,
AppBskySystemDeclRef,
AppBskyFeedPost,
} from '@atproto/api'
type DeclRef = AppBskySystemDeclRef.Main
type Entity = AppBskyFeedPost.Entity
import {extractEntities} from '../../lib/strings'
import {extractEntities} from 'lib/strings/rich-text-detection'
import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {RichText} from 'lib/strings/rich-text'
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
export class ProfileViewMyStateModel {
follow?: string
export class ProfileViewViewerModel {
muted?: boolean
following?: string
followedBy?: string
constructor() {
makeAutoObservable(this)
@ -46,10 +47,10 @@ export class ProfileViewModel {
followersCount: number = 0
followsCount: number = 0
postsCount: number = 0
myState = new ProfileViewMyStateModel()
viewer = new ProfileViewViewerModel()
// added data
descriptionEntities?: Entity[]
descriptionRichText?: RichText
constructor(
public rootStore: RootStoreModel,
@ -97,11 +98,24 @@ export class ProfileViewModel {
if (!this.rootStore.me.did) {
throw new Error('Not logged in')
}
if (this.myState.follow) {
await apilib.unfollow(this.rootStore, this.myState.follow)
const follows = this.rootStore.me.follows
const followUri = follows.isFollowing(this.did)
? follows.getFollowUri(this.did)
: undefined
// guard against this view getting out of sync with the follows cache
if (followUri !== this.viewer.following) {
this.viewer.following = followUri
return
}
if (followUri) {
await apilib.unfollow(this.rootStore, followUri)
runInAction(() => {
this.followersCount--
this.myState.follow = undefined
this.viewer.following = undefined
this.rootStore.me.follows.removeFollow(this.did)
})
} else {
const res = await apilib.follow(
@ -111,15 +125,16 @@ export class ProfileViewModel {
)
runInAction(() => {
this.followersCount++
this.myState.follow = res.uri
this.viewer.following = res.uri
this.rootStore.me.follows.addFollow(this.did, res.uri)
})
}
}
async updateProfile(
updates: Profile.Record,
newUserAvatar: PickedImage | undefined,
newUserBanner: PickedImage | undefined,
newUserAvatar: PickedMedia | undefined,
newUserBanner: PickedMedia | undefined,
) {
if (newUserAvatar) {
const res = await this.rootStore.api.com.atproto.blob.upload(
@ -152,13 +167,13 @@ export class ProfileViewModel {
async muteAccount() {
await this.rootStore.api.app.bsky.graph.mute({user: this.did})
this.myState.muted = true
this.viewer.muted = true
await this.refresh()
}
async unmuteAccount() {
await this.rootStore.api.app.bsky.graph.unmute({user: this.did})
this.myState.muted = false
this.viewer.muted = false
await this.refresh()
}
@ -175,7 +190,7 @@ export class ProfileViewModel {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err ? err.toString() : ''
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch profile', err)
}
@ -210,9 +225,14 @@ export class ProfileViewModel {
this.followersCount = res.data.followersCount
this.followsCount = res.data.followsCount
this.postsCount = res.data.postsCount
if (res.data.myState) {
Object.assign(this.myState, res.data.myState)
if (res.data.viewer) {
Object.assign(this.viewer, res.data.viewer)
this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
}
this.descriptionEntities = extractEntities(this.description || '')
this.descriptionRichText = new RichText(
this.description || '',
extractEntities(this.description || ''),
{cleanNewlines: true},
)
}
}

View file

@ -31,7 +31,9 @@ export class ProfilesViewModel {
}
}
try {
const promise = this.rootStore.api.app.bsky.actor.getProfile({actor: did})
const promise = this.rootStore.api.app.bsky.actor.getProfile({
actor: did,
})
this.cache.set(did, promise)
const res = await promise
this.cache.set(did, res)

View file

@ -1,11 +1,17 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {AtUri} from '../../third-party/uri'
import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api'
import {
AppBskyFeedGetRepostedBy as GetRepostedBy,
AppBskyActorRef as ActorRef,
} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import * as apilib from 'lib/api/index'
const PAGE_SIZE = 30
export type RepostedByItem = GetRepostedBy.RepostedBy
export type RepostedByItem = ActorRef.WithInfo
export class RepostedByViewModel {
// state
@ -17,7 +23,6 @@ export class RepostedByViewModel {
params: GetRepostedBy.QueryParams
hasMore = true
loadMoreCursor?: string
private _loadMorePromise: Promise<void> | undefined
// data
uri: string = ''
@ -57,17 +62,28 @@ export class RepostedByViewModel {
return this.loadMore(true)
}
async loadMore(isRefreshing = false) {
if (this._loadMorePromise) {
return this._loadMorePromise
loadMore = bundleAsync(async (replace: boolean = false) => {
this._xLoading(replace)
try {
if (!this.resolvedUri) {
await this._resolveUri()
}
const params = Object.assign({}, this.params, {
uri: this.resolvedUri,
limit: PAGE_SIZE,
before: replace ? undefined : this.loadMoreCursor,
})
const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params)
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
if (!this.resolvedUri) {
await this._resolveUri()
}
this._loadMorePromise = this._loadMore(isRefreshing)
await this._loadMorePromise
this._loadMorePromise = undefined
}
})
// state transitions
// =
@ -82,20 +98,20 @@ export class RepostedByViewModel {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err ? err.toString() : ''
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch reposted by view', err)
}
}
// loader functions
// helper functions
// =
private async _resolveUri() {
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
try {
urip.host = await this.rootStore.resolveName(urip.host)
urip.host = await apilib.resolveName(this.rootStore, urip.host)
} catch (e: any) {
this.error = e.toString()
}
@ -105,28 +121,15 @@ export class RepostedByViewModel {
})
}
private async _loadMore(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const params = Object.assign({}, this.params, {
uri: this.resolvedUri,
limit: PAGE_SIZE,
before: this.loadMoreCursor,
})
if (this.isRefreshing) {
this.repostedBy = []
}
const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params)
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
private _replaceAll(res: GetRepostedBy.Response) {
this.repostedBy = []
this._appendAll(res)
}
private _appendAll(res: GetRepostedBy.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy)
}
}

View file

@ -3,71 +3,63 @@
*/
import {makeAutoObservable} from 'mobx'
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
import {AtpAgent} from '@atproto/api'
import {createContext, useContext} from 'react'
import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
import * as BgScheduler from '../lib/bg-scheduler'
import {isObj, hasProp} from '../lib/type-guards'
import * as BgScheduler from 'lib/bg-scheduler'
import {z} from 'zod'
import {isObj, hasProp} from 'lib/type-guards'
import {LogModel} from './log'
import {SessionModel} from './session'
import {NavigationModel} from './navigation'
import {ShellUiModel} from './shell-ui'
import {ProfilesViewModel} from './profiles-view'
import {LinkMetasViewModel} from './link-metas-view'
import {NotificationsViewItemModel} from './notifications-view'
import {MeModel} from './me'
import {OnboardModel} from './onboard'
import {isNetworkError} from '../../lib/errors'
export const appInfo = z.object({
build: z.string(),
name: z.string(),
namespace: z.string(),
version: z.string(),
})
export type AppInfo = z.infer<typeof appInfo>
export class RootStoreModel {
agent: AtpAgent
appInfo?: AppInfo
log = new LogModel()
session = new SessionModel(this)
nav = new NavigationModel()
shell = new ShellUiModel()
nav = new NavigationModel(this)
shell = new ShellUiModel(this)
me = new MeModel(this)
onboard = new OnboardModel()
profiles = new ProfilesViewModel(this)
linkMetas = new LinkMetasViewModel(this)
constructor(public api: SessionServiceClient) {
constructor(agent: AtpAgent) {
this.agent = agent
makeAutoObservable(this, {
api: false,
resolveName: false,
serialize: false,
hydrate: false,
})
this.initBgFetch()
}
async resolveName(didOrHandle: string) {
if (!didOrHandle) {
throw new Error('Invalid handle: ""')
}
if (didOrHandle.startsWith('did:')) {
return didOrHandle
}
const res = await this.api.com.atproto.handle.resolve({handle: didOrHandle})
return res.data.did
get api() {
return this.agent.api
}
async fetchStateUpdate() {
if (!this.session.hasSession) {
return
}
try {
if (!this.session.online) {
await this.session.connect()
}
await this.me.fetchNotifications()
} catch (e: any) {
if (isNetworkError(e)) {
this.session.setOnline(false) // connection lost
}
this.log.error('Failed to fetch latest state', e)
}
setAppInfo(info: AppInfo) {
this.appInfo = info
}
serialize(): unknown {
return {
appInfo: this.appInfo,
log: this.log.serialize(),
session: this.session.serialize(),
me: this.me.serialize(),
@ -79,6 +71,12 @@ export class RootStoreModel {
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'appInfo')) {
const appInfoParsed = appInfo.safeParse(v.appInfo)
if (appInfoParsed.success) {
this.setAppInfo(appInfoParsed.data)
}
}
if (hasProp(v, 'log')) {
this.log.hydrate(v.log)
}
@ -100,20 +98,131 @@ export class RootStoreModel {
}
}
clearAll() {
/**
* Called during init to resume any stored session.
*/
async attemptSessionResumption() {
this.log.debug('RootStoreModel:attemptSessionResumption')
try {
await this.session.attemptSessionResumption()
this.log.debug('Session initialized', {
hasSession: this.session.hasSession,
})
this.updateSessionState()
} catch (e: any) {
this.log.warn('Failed to initialize session', e)
}
}
/**
* Called by the session model. Refreshes session-oriented state.
*/
async handleSessionChange(agent: AtpAgent) {
this.log.debug('RootStoreModel:handleSessionChange')
this.agent = agent
this.nav.clear()
this.me.clear()
await this.me.load()
}
/**
* Called by the session model. Handles session drops by informing the user.
*/
async handleSessionDrop() {
this.log.debug('RootStoreModel:handleSessionDrop')
this.nav.clear()
this.me.clear()
this.emitSessionDropped()
}
/**
* Clears all session-oriented state.
*/
clearAllSessionState() {
this.log.debug('RootStoreModel:clearAllSessionState')
this.session.clear()
this.nav.clear()
this.me.clear()
}
/**
* Periodic poll for new session state.
*/
async updateSessionState() {
if (!this.session.hasSession) {
return
}
try {
await this.me.follows.fetchIfNeeded()
} catch (e: any) {
this.log.error('Failed to fetch latest state', e)
}
}
// global event bus
// =
// - some events need to be passed around between views and models
// in order to keep state in sync; these methods are for that
// a post was deleted by the local user
onPostDeleted(handler: (uri: string) => void): EmitterSubscription {
return DeviceEventEmitter.addListener('post-deleted', handler)
}
emitPostDeleted(uri: string) {
DeviceEventEmitter.emit('post-deleted', uri)
}
// the session has started and been fully hydrated
onSessionLoaded(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('session-loaded', handler)
}
emitSessionLoaded() {
DeviceEventEmitter.emit('session-loaded')
}
// the session was dropped due to bad/expired refresh tokens
onSessionDropped(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('session-dropped', handler)
}
emitSessionDropped() {
DeviceEventEmitter.emit('session-dropped')
}
// the current screen has changed
onNavigation(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('navigation', handler)
}
emitNavigation() {
DeviceEventEmitter.emit('navigation')
}
// a "soft reset" typically means scrolling to top and loading latest
// but it can depend on the screen
onScreenSoftReset(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('screen-soft-reset', handler)
}
emitScreenSoftReset() {
DeviceEventEmitter.emit('screen-soft-reset')
}
// the unread notifications count has changed
onUnreadNotifications(handler: (count: number) => void): EmitterSubscription {
return DeviceEventEmitter.addListener('unread-notifications', handler)
}
emitUnreadNotifications(count: number) {
DeviceEventEmitter.emit('unread-notifications', count)
}
// a notification has been queued for push
onPushNotification(
handler: (notif: NotificationsViewItemModel) => void,
): EmitterSubscription {
return DeviceEventEmitter.addListener('push-notification', handler)
}
emitPushNotification(notif: NotificationsViewItemModel) {
DeviceEventEmitter.emit('push-notification', notif)
}
// background fetch
// =
// - we use this to poll for unread notifications, which is not "ideal" behavior but
@ -135,7 +244,22 @@ export class RootStoreModel {
async onBgFetch(taskId: string) {
this.log.debug(`Background fetch fired for task ${taskId}`)
if (this.session.hasSession) {
await this.me.bgFetchNotifications()
const res = await this.api.app.bsky.notification.getCount()
const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count
this.emitUnreadNotifications(res.data.count)
this.log.debug(
`Background fetch received unread count = ${res.data.count}`,
)
if (hasNewNotifs) {
this.log.debug(
'Background fetch detected potentially a new notification',
)
const mostRecent = await this.me.notifications.getNewMostRecent()
if (mostRecent) {
this.log.debug('Got the notification, triggering a push')
this.emitPushNotification(mostRecent)
}
}
}
BgScheduler.finish(taskId)
}
@ -146,7 +270,9 @@ export class RootStoreModel {
}
}
const throwawayInst = new RootStoreModel(AtpApi.service('http://localhost')) // this will be replaced by the loader, we just need to supply a value at init
const throwawayInst = new RootStoreModel(
new AtpAgent({service: 'http://localhost'}),
) // this will be replaced by the loader, we just need to supply a value at init
const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
export const RootStoreProvider = RootStoreContext.Provider
export const useStores = () => useContext(RootStoreContext)

View file

@ -1,25 +1,22 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {makeAutoObservable} from 'mobx'
import {
sessionClient as AtpApi,
Session,
SessionServiceClient,
AtpAgent,
AtpSessionEvent,
AtpSessionData,
ComAtprotoServerGetAccountsConfig as GetAccountsConfig,
} from '@atproto/api'
import {isObj, hasProp} from '../lib/type-guards'
import normalizeUrl from 'normalize-url'
import {isObj, hasProp} from 'lib/type-guards'
import {z} from 'zod'
import {RootStoreModel} from './root-store'
import {isNetworkError} from '../../lib/errors'
export type ServiceDescription = GetAccountsConfig.OutputSchema
export const sessionData = z.object({
export const activeSession = z.object({
service: z.string(),
refreshJwt: z.string(),
accessJwt: z.string(),
handle: z.string(),
did: z.string(),
})
export type SessionData = z.infer<typeof sessionData>
export type ActiveSession = z.infer<typeof activeSession>
export const accountData = z.object({
service: z.string(),
@ -32,18 +29,24 @@ export const accountData = z.object({
})
export type AccountData = z.infer<typeof accountData>
interface AdditionalAccountData {
displayName?: string
aviUrl?: string
}
export class SessionModel {
/**
* Current session data
* Currently-active session
*/
data: SessionData | null = null
data: ActiveSession | null = null
/**
* A listing of the currently & previous sessions, used for account switching
* A listing of the currently & previous sessions
*/
accounts: AccountData[] = []
online = false
attemptingConnect = false
private _connectPromise: Promise<boolean> | undefined
/**
* Flag to indicate if we're doing our initial-load session resumption
*/
isResumingSession = false
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
@ -53,8 +56,22 @@ export class SessionModel {
})
}
get currentSession() {
if (!this.data) {
return undefined
}
const {did, service} = this.data
return this.accounts.find(
account =>
normalizeUrl(account.service) === normalizeUrl(service) &&
account.did === did &&
!!account.accessJwt &&
!!account.refreshJwt,
)
}
get hasSession() {
return this.data !== null
return !!this.currentSession && !!this.rootStore.agent.session
}
get hasAccounts() {
@ -75,8 +92,8 @@ export class SessionModel {
hydrate(v: unknown) {
this.accounts = []
if (isObj(v)) {
if (hasProp(v, 'data') && sessionData.safeParse(v.data)) {
this.data = v.data as SessionData
if (hasProp(v, 'data') && activeSession.safeParse(v.data)) {
this.data = v.data as ActiveSession
}
if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) {
for (const account of v.accounts) {
@ -90,92 +107,96 @@ export class SessionModel {
clear() {
this.data = null
this.setOnline(false)
}
setState(data: SessionData) {
this.data = data
}
setOnline(online: boolean, attemptingConnect?: boolean) {
this.online = online
if (typeof attemptingConnect === 'boolean') {
this.attemptingConnect = attemptingConnect
}
}
updateAuthTokens(session: Session) {
if (this.data) {
this.setState({
...this.data,
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
})
}
}
/**
* Sets up the XRPC API, must be called before connecting to a service
* Attempts to resume the previous session loaded from storage
*/
private configureApi(): boolean {
if (!this.data) {
return false
}
try {
const serviceUri = new URL(this.data.service)
this.rootStore.api.xrpc.uri = serviceUri
} catch (e: any) {
this.rootStore.log.error(
`Invalid service URL: ${this.data.service}. Resetting session.`,
e,
async attemptSessionResumption() {
const sess = this.currentSession
if (sess) {
this.rootStore.log.debug(
'SessionModel:attemptSessionResumption found stored session',
)
this.isResumingSession = true
try {
return await this.resumeSession(sess)
} finally {
this.isResumingSession = false
}
} else {
this.rootStore.log.debug(
'SessionModel:attemptSessionResumption has no session to resume',
)
this.clear()
return false
}
this.rootStore.api.sessionManager.set({
refreshJwt: this.data.refreshJwt,
accessJwt: this.data.accessJwt,
})
return true
}
/**
* Upserts the current session into the accounts
* Sets the active session
*/
private addSessionToAccounts() {
if (!this.data) {
return
setActiveSession(agent: AtpAgent, did: string) {
this.rootStore.log.debug('SessionModel:setActiveSession')
this.data = {
service: agent.service.toString(),
did,
}
this.rootStore.handleSessionChange(agent)
}
/**
* Upserts a session into the accounts
*/
private persistSession(
service: string,
did: string,
event: AtpSessionEvent,
session?: AtpSessionData,
addedInfo?: AdditionalAccountData,
) {
this.rootStore.log.debug('SessionModel:persistSession', {
service,
did,
event,
hasSession: !!session,
})
// upsert the account in our listing
const existingAccount = this.accounts.find(
acc => acc.service === this.data?.service && acc.did === this.data.did,
account => account.service === service && account.did === did,
)
const newAccount = {
service: this.data.service,
refreshJwt: this.data.refreshJwt,
accessJwt: this.data.accessJwt,
handle: this.data.handle,
did: this.data.did,
displayName: this.rootStore.me.displayName,
aviUrl: this.rootStore.me.avatar,
service,
did,
refreshJwt: session?.refreshJwt,
accessJwt: session?.accessJwt,
handle: session?.handle || existingAccount?.handle || '',
displayName: addedInfo
? addedInfo.displayName
: existingAccount?.displayName || '',
aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
}
if (!existingAccount) {
this.accounts.push(newAccount)
} else {
this.accounts = this.accounts
.filter(
acc =>
!(acc.service === this.data?.service && acc.did === this.data.did),
)
.concat([newAccount])
this.accounts = [
newAccount,
...this.accounts.filter(
account => !(account.service === service && account.did === did),
),
]
}
// if the session expired, fire an event to let the user know
if (event === 'expired') {
this.rootStore.handleSessionDrop()
}
}
/**
* Clears any session tokens from the accounts; used on logout.
*/
private clearSessionTokensFromAccounts() {
private clearSessionTokens() {
this.rootStore.log.debug('SessionModel:clearSessionTokens')
this.accounts = this.accounts.map(acct => ({
service: acct.service,
handle: acct.handle,
@ -186,144 +207,106 @@ export class SessionModel {
}
/**
* Fetches the current session from the service, if possible.
* Requires an existing session (.data) to be populated with access tokens.
* Fetches additional information about an account on load.
*/
async connect(): Promise<boolean> {
if (this._connectPromise) {
return this._connectPromise
}
this._connectPromise = this._connect()
const res = await this._connectPromise
this._connectPromise = undefined
return res
}
private async _connect(): Promise<boolean> {
this.attemptingConnect = true
if (!this.configureApi()) {
return false
}
try {
const sess = await this.rootStore.api.com.atproto.session.get()
if (sess.success && this.data && this.data.did === sess.data.did) {
this.setOnline(true, false)
if (this.rootStore.me.did !== sess.data.did) {
this.rootStore.me.clear()
}
this.rootStore.me
.load()
.catch(e => {
this.rootStore.log.error(
'Failed to fetch local user information',
e,
)
})
.then(() => {
this.addSessionToAccounts()
})
return true // success
}
} catch (e: any) {
if (isNetworkError(e)) {
this.setOnline(false, false) // connection issue
return false
} else {
this.clear() // invalid session cached
private async loadAccountInfo(agent: AtpAgent, did: string) {
const res = await agent.api.app.bsky.actor
.getProfile({actor: did})
.catch(_e => undefined)
if (res) {
return {
dispayName: res.data.displayName,
aviUrl: res.data.avatar,
}
}
this.setOnline(false, false)
return false
}
/**
* Helper to fetch the accounts config settings from an account.
*/
async describeService(service: string): Promise<ServiceDescription> {
const api = AtpApi.service(service) as SessionServiceClient
const res = await api.com.atproto.server.getAccountsConfig({})
const agent = new AtpAgent({service})
const res = await agent.api.com.atproto.server.getAccountsConfig({})
return res.data
}
/**
* Attempt to resume a session that we still have access tokens for.
*/
async resumeSession(account: AccountData): Promise<boolean> {
this.rootStore.log.debug('SessionModel:resumeSession')
if (!(account.accessJwt && account.refreshJwt && account.service)) {
this.rootStore.log.debug(
'SessionModel:resumeSession aborted due to lack of access tokens',
)
return false
}
const agent = new AtpAgent({
service: account.service,
persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(account.service, account.did, evt, sess)
},
})
try {
await agent.resumeSession({
accessJwt: account.accessJwt,
refreshJwt: account.refreshJwt,
did: account.did,
handle: account.handle,
})
const addedInfo = await this.loadAccountInfo(agent, account.did)
this.persistSession(
account.service,
account.did,
'create',
agent.session,
addedInfo,
)
this.rootStore.log.debug('SessionModel:resumeSession succeeded')
} catch (e: any) {
this.rootStore.log.debug('SessionModel:resumeSession failed', {
error: e.toString(),
})
return false
}
this.setActiveSession(agent, account.did)
return true
}
/**
* Create a new session.
*/
async login({
service,
handle,
identifier,
password,
}: {
service: string
handle: string
identifier: string
password: string
}) {
const api = AtpApi.service(service) as SessionServiceClient
const res = await api.com.atproto.session.create({handle, password})
if (res.data.accessJwt && res.data.refreshJwt) {
this.setState({
service: service,
accessJwt: res.data.accessJwt,
refreshJwt: res.data.refreshJwt,
handle: res.data.handle,
did: res.data.did,
})
this.configureApi()
this.setOnline(true, false)
this.rootStore.me
.load()
.catch(e => {
this.rootStore.log.error('Failed to fetch local user information', e)
})
.then(() => {
this.addSessionToAccounts()
})
}
}
/**
* Attempt to resume a session that we still have access tokens for.
*/
async resumeSession(account: AccountData): Promise<boolean> {
if (!(account.accessJwt && account.refreshJwt && account.service)) {
return false
this.rootStore.log.debug('SessionModel:login')
const agent = new AtpAgent({service})
await agent.login({identifier, password})
if (!agent.session) {
throw new Error('Failed to establish session')
}
// test that the session is good
const api = AtpApi.service(account.service)
api.sessionManager.set({
refreshJwt: account.refreshJwt,
accessJwt: account.accessJwt,
})
try {
const sess = await api.com.atproto.session.get()
if (
!sess.success ||
sess.data.did !== account.did ||
!api.sessionManager.session
) {
return false
}
const did = agent.session.did
const addedInfo = await this.loadAccountInfo(agent, did)
// copy over the access tokens, as they may have refreshed during the .get() above
runInAction(() => {
account.refreshJwt = api.sessionManager.session?.refreshJwt
account.accessJwt = api.sessionManager.session?.accessJwt
})
} catch (_e) {
return false
}
this.persistSession(service, did, 'create', agent.session, addedInfo)
agent.setPersistSessionHandler(
(evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(service, did, evt, sess)
},
)
// session is good, connect
this.setState({
service: account.service,
accessJwt: account.accessJwt,
refreshJwt: account.refreshJwt,
handle: account.handle,
did: account.did,
})
return this.connect()
this.setActiveSession(agent, did)
this.rootStore.log.debug('SessionModel:login succeeded')
}
async createAccount({
@ -339,38 +322,41 @@ export class SessionModel {
handle: string
inviteCode?: string
}) {
const api = AtpApi.service(service) as SessionServiceClient
const res = await api.com.atproto.account.create({
this.rootStore.log.debug('SessionModel:createAccount')
const agent = new AtpAgent({service})
await agent.createAccount({
handle,
password,
email,
inviteCode,
})
if (res.data.accessJwt && res.data.refreshJwt) {
this.setState({
service: service,
accessJwt: res.data.accessJwt,
refreshJwt: res.data.refreshJwt,
handle: res.data.handle,
did: res.data.did,
})
this.rootStore.onboard.start()
this.configureApi()
this.rootStore.me
.load()
.catch(e => {
this.rootStore.log.error('Failed to fetch local user information', e)
})
.then(() => {
this.addSessionToAccounts()
})
if (!agent.session) {
throw new Error('Failed to establish session')
}
const did = agent.session.did
const addedInfo = await this.loadAccountInfo(agent, did)
this.persistSession(service, did, 'create', agent.session, addedInfo)
agent.setPersistSessionHandler(
(evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(service, did, evt, sess)
},
)
this.setActiveSession(agent, did)
this.rootStore.onboard.start()
this.rootStore.log.debug('SessionModel:createAccount succeeded')
}
/**
* Close all sessions across all accounts.
*/
async logout() {
this.rootStore.log.debug('SessionModel:logout')
// TODO
// need to evaluate why deleting the session has caused errors at times
// -prf
/*if (this.hasSession) {
this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
this.rootStore.log.warn(
@ -379,7 +365,7 @@ export class SessionModel {
)
})
}*/
this.clearSessionTokensFromAccounts()
this.rootStore.clearAll()
this.clearSessionTokens()
this.rootStore.clearAllSessionState()
}
}

View file

@ -1,7 +1,8 @@
import {RootStoreModel} from './root-store'
import {makeAutoObservable} from 'mobx'
import {ProfileViewModel} from './profile-view'
import {isObj, hasProp} from '../lib/type-guards'
import {PickedMedia} from '../../view/com/util/images/image-crop-picker/types'
import {isObj, hasProp} from 'lib/type-guards'
import {PickedMedia} from 'view/com/util/images/image-crop-picker/types'
export class ConfirmModal {
name = 'confirm'
@ -40,7 +41,7 @@ export class ServerInputModal {
export class ReportPostModal {
name = 'report-post'
constructor(public postUrl: string) {
constructor(public postUri: string, public postCid: string) {
makeAutoObservable(this)
}
}
@ -59,7 +60,13 @@ export class CropImageModal {
constructor(
public uri: string,
public onSelect: (img?: PickedMedia) => void,
) {
) {}
}
export class DeleteAccountModal {
name = 'delete-account'
constructor() {
makeAutoObservable(this)
}
}
@ -111,14 +118,19 @@ export class ShellUiModel {
| ReportPostModal
| ReportAccountModal
| CropImageModal
| DeleteAccountModal
| undefined
isLightboxActive = false
activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
isComposerActive = false
composerOpts: ComposerOpts | undefined
constructor() {
makeAutoObservable(this, {serialize: false, hydrate: false})
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
serialize: false,
rootStore: false,
hydrate: false,
})
}
serialize(): unknown {
@ -154,8 +166,10 @@ export class ShellUiModel {
| ServerInputModal
| ReportPostModal
| ReportAccountModal
| CropImageModal,
| CropImageModal
| DeleteAccountModal,
) {
this.rootStore.emitNavigation()
this.isModalActive = true
this.activeModal = modal
}
@ -166,6 +180,7 @@ export class ShellUiModel {
}
openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) {
this.rootStore.emitNavigation()
this.isLightboxActive = true
this.activeLightbox = lightbox
}
@ -176,6 +191,7 @@ export class ShellUiModel {
}
openComposer(opts: ComposerOpts) {
this.rootStore.emitNavigation()
this.isComposerActive = true
this.composerOpts = opts
}

View file

@ -1,25 +1,48 @@
import {makeAutoObservable} from 'mobx'
import {AppBskyActorGetSuggestions as GetSuggestions} from '@atproto/api'
import {makeAutoObservable, runInAction} from 'mobx'
import {AppBskyActorProfile as Profile} from '@atproto/api'
import shuffle from 'lodash.shuffle'
import {RootStoreModel} from './root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {
DEV_SUGGESTED_FOLLOWS,
PROD_SUGGESTED_FOLLOWS,
STAGING_SUGGESTED_FOLLOWS,
} from 'lib/constants'
const PAGE_SIZE = 30
export type SuggestedActor = GetSuggestions.Actor
export type SuggestedActor = Profile.ViewBasic | Profile.View
const getSuggestionList = ({serviceUrl}: {serviceUrl: string}) => {
if (serviceUrl.includes('localhost')) {
return DEV_SUGGESTED_FOLLOWS
} else if (serviceUrl.includes('staging')) {
return STAGING_SUGGESTED_FOLLOWS
} else {
return PROD_SUGGESTED_FOLLOWS
}
}
export class SuggestedActorsViewModel {
// state
pageSize = PAGE_SIZE
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
hasMore = true
loadMoreCursor?: string
private _loadMorePromise: Promise<void> | undefined
private hardCodedSuggestions: SuggestedActor[] | undefined
// data
suggestions: SuggestedActor[] = []
constructor(public rootStore: RootStoreModel) {
constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) {
if (opts?.pageSize) {
this.pageSize = opts.pageSize
}
makeAutoObservable(
this,
{
@ -48,13 +71,96 @@ export class SuggestedActorsViewModel {
return this.loadMore(true)
}
async loadMore(isRefreshing = false) {
if (this._loadMorePromise) {
return this._loadMorePromise
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
if (replace) {
this.hardCodedSuggestions = undefined
}
this._xLoading(replace)
try {
let items: SuggestedActor[] = this.suggestions
if (replace) {
items = []
this.loadMoreCursor = undefined
}
let res
do {
await this.fetchHardcodedSuggestions()
if (this.hardCodedSuggestions && this.hardCodedSuggestions.length > 0) {
// pull from the hard-coded suggestions
const newItems = this.hardCodedSuggestions.splice(0, this.pageSize)
items = items.concat(newItems)
this.hasMore = true
this.loadMoreCursor = undefined
} else {
// pull from the PDS' algo
res = await this.rootStore.api.app.bsky.actor.getSuggestions({
limit: this.pageSize,
cursor: this.loadMoreCursor,
})
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
items = items.concat(
res.data.actors.filter(
actor => !items.find(i => i.did === actor.did),
),
)
}
} while (items.length < this.pageSize && this.hasMore)
runInAction(() => {
this.suggestions = items
})
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
private async fetchHardcodedSuggestions() {
if (this.hardCodedSuggestions) {
return
}
await this.rootStore.me.follows.fetchIfNeeded()
try {
// clone the array so we can mutate it
const actors = [
...getSuggestionList({
serviceUrl: this.rootStore.session.currentSession?.service || '',
}),
]
// fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)
let profiles: Profile.View[] = []
do {
const res = await this.rootStore.api.app.bsky.actor.getProfiles({
actors: actors.splice(0, 25),
})
profiles = profiles.concat(res.data.profiles)
} while (actors.length)
runInAction(() => {
profiles = profiles.filter(profile => {
if (this.rootStore.me.follows.isFollowing(profile.did)) {
return false
}
if (profile.did === this.rootStore.me.did) {
return false
}
return true
})
this.hardCodedSuggestions = shuffle(profiles)
})
} catch (e) {
this.rootStore.log.error(
'Failed to getProfiles() for suggested follows',
{e},
)
runInAction(() => {
this.hardCodedSuggestions = []
})
}
this._loadMorePromise = this._loadMore(isRefreshing)
await this._loadMorePromise
this._loadMorePromise = undefined
}
// state transitions
@ -70,52 +176,9 @@ export class SuggestedActorsViewModel {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err ? err.toString() : ''
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch suggested actors', err)
}
}
// loader functions
// =
private async _loadMore(isRefreshing = false) {
if (!this.hasMore) {
return
}
this._xLoading(isRefreshing)
try {
if (this.isRefreshing) {
this.suggestions = []
}
let res
let totalAdded = 0
do {
res = await this.rootStore.api.app.bsky.actor.getSuggestions({
limit: PAGE_SIZE,
cursor: this.loadMoreCursor,
})
totalAdded += await this._appendAll(res)
} while (totalAdded < PAGE_SIZE && this.hasMore)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
private async _appendAll(res: GetSuggestions.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
const newSuggestions = res.data.actors.filter(actor => {
if (actor.did === this.rootStore.me.did) {
return false // skip self
}
if (actor.myState?.follow) {
return false // skip already-followed users
}
return true
})
this.suggestions = this.suggestions.concat(newSuggestions)
return newSuggestions.length
}
}

View file

@ -0,0 +1,148 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AppBskyFeedFeedViewPost,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
} from '@atproto/api'
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
import {RootStoreModel} from './root-store'
import {FeedItemModel} from './feed-view'
import {cleanError} from 'lib/strings/errors'
const TEAM_HANDLES = [
'jay.bsky.social',
'paul.bsky.social',
'dan.bsky.social',
'divy.bsky.social',
'why.bsky.social',
'iamrosewang.bsky.social',
]
export class SuggestedPostsView {
// state
isLoading = false
hasLoaded = false
error = ''
// data
posts: FeedItemModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.posts.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async setup() {
this._xLoading()
try {
const responses = await Promise.all(
TEAM_HANDLES.map(handle =>
this.rootStore.api.app.bsky.feed
.getAuthorFeed({author: handle, limit: 10})
.catch(_err => ({success: false, headers: {}, data: {feed: []}})),
),
)
runInAction(() => {
this.posts = mergeAndFilterResponses(this.rootStore, responses)
})
this._xIdle()
} catch (e: any) {
this.rootStore.log.error('SuggestedPostsView: Failed to load posts', {
e,
})
this._xIdle() // dont bubble to the user
}
}
// state transitions
// =
private _xLoading() {
this.isLoading = true
this.error = ''
}
private _xIdle(err?: any) {
this.isLoading = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch suggested posts', err)
}
}
}
function mergeAndFilterResponses(
store: RootStoreModel,
responses: GetAuthorFeed.Response[],
): FeedItemModel[] {
let posts: AppBskyFeedFeedViewPost.Main[] = []
// merge into one array
for (const res of responses) {
if (res.success) {
posts = posts.concat(res.data.feed)
}
}
// filter down to reposts of other users
const now = Date.now()
const uris = new Set()
posts = posts.filter(p => {
if (isARepostOfSomeoneElse(p) && isRecentEnough(now, p)) {
if (uris.has(p.post.uri)) {
return false
}
uris.add(p.post.uri)
return true
}
return false
})
// sort by index time
posts.sort((a, b) => {
return (
Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt))
)
})
// hydrate into models and strip the reasons to hide that these are reposts
return posts.map((post, i) => {
delete post.reason
return new FeedItemModel(store, `post-${i}`, post)
})
}
function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean {
return (
post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' &&
post.post.author.did !== (post.reason as ReasonRepost).by.did
)
}
const THREE_DAYS = 3 * 24 * 60 * 60 * 1000
function isRecentEnough(
now: number,
post: AppBskyFeedFeedViewPost.Main,
): boolean {
return now - Number(new Date(post.post.indexedAt)) < THREE_DAYS
}

View file

@ -1,8 +1,6 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AppBskyGraphGetFollows as GetFollows,
AppBskyActorSearchTypeahead as SearchTypeahead,
} from '@atproto/api'
import {AppBskyActorRef} from '@atproto/api'
import AwaitLock from 'await-lock'
import {RootStoreModel} from './root-store'
export class UserAutocompleteViewModel {
@ -10,11 +8,11 @@ export class UserAutocompleteViewModel {
isLoading = false
isActive = false
prefix = ''
_searchPromise: Promise<any> | undefined
lock = new AwaitLock()
// data
follows: GetFollows.Follow[] = []
searchRes: SearchTypeahead.User[] = []
follows: AppBskyActorRef.WithInfo[] = []
searchRes: AppBskyActorRef.WithInfo[] = []
knownHandles: Set<string> = new Set()
constructor(public rootStore: RootStoreModel) {
@ -58,16 +56,20 @@ export class UserAutocompleteViewModel {
}
async setPrefix(prefix: string) {
const origPrefix = prefix
this.prefix = prefix.trim()
if (this.prefix) {
await this._searchPromise
if (this.prefix !== origPrefix) {
return // another prefix was set before we got our chance
const origPrefix = prefix.trim()
this.prefix = origPrefix
await this.lock.acquireAsync()
try {
if (this.prefix) {
if (this.prefix !== origPrefix) {
return // another prefix was set before we got our chance
}
await this._search()
} else {
this.searchRes = []
}
this._searchPromise = this._search()
} else {
this.searchRes = []
} finally {
this.lock.release()
}
}

View file

@ -4,10 +4,12 @@ import {
AppBskyActorRef as ActorRef,
} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
const PAGE_SIZE = 30
export type FollowerItem = GetFollowers.Follower
export type FollowerItem = ActorRef.WithInfo
export class UserFollowersViewModel {
// state
@ -18,7 +20,6 @@ export class UserFollowersViewModel {
params: GetFollowers.QueryParams
hasMore = true
loadMoreCursor?: string
private _loadMorePromise: Promise<void> | undefined
// data
subject: ActorRef.WithInfo = {
@ -62,14 +63,27 @@ export class UserFollowersViewModel {
return this.loadMore(true)
}
async loadMore(isRefreshing = false) {
if (this._loadMorePromise) {
return this._loadMorePromise
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._loadMorePromise = this._loadMore(isRefreshing)
await this._loadMorePromise
this._loadMorePromise = undefined
}
this._xLoading(replace)
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
before: replace ? undefined : this.loadMoreCursor,
})
const res = await this.rootStore.api.app.bsky.graph.getFollowers(params)
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
@ -84,39 +98,24 @@ export class UserFollowersViewModel {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err ? err.toString() : ''
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch user followers', err)
}
}
// loader functions
// helper functions
// =
private async _loadMore(isRefreshing = false) {
if (!this.hasMore) {
return
}
this._xLoading(isRefreshing)
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
before: this.loadMoreCursor,
})
if (this.isRefreshing) {
this.followers = []
}
const res = await this.rootStore.api.app.bsky.graph.getFollowers(params)
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
private _replaceAll(res: GetFollowers.Response) {
this.followers = []
this._appendAll(res)
}
private async _appendAll(res: GetFollowers.Response) {
private _appendAll(res: GetFollowers.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.followers = this.followers.concat(res.data.followers)
this.rootStore.me.follows.hydrateProfiles(res.data.followers)
}
}

View file

@ -4,10 +4,12 @@ import {
AppBskyActorRef as ActorRef,
} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
const PAGE_SIZE = 30
export type FollowItem = GetFollows.Follow
export type FollowItem = ActorRef.WithInfo
export class UserFollowsViewModel {
// state
@ -18,7 +20,6 @@ export class UserFollowsViewModel {
params: GetFollows.QueryParams
hasMore = true
loadMoreCursor?: string
private _loadMorePromise: Promise<void> | undefined
// data
subject: ActorRef.WithInfo = {
@ -62,14 +63,27 @@ export class UserFollowsViewModel {
return this.loadMore(true)
}
async loadMore(isRefreshing = false) {
if (this._loadMorePromise) {
return this._loadMorePromise
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._loadMorePromise = this._loadMore(isRefreshing)
await this._loadMorePromise
this._loadMorePromise = undefined
}
this._xLoading(replace)
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
before: replace ? undefined : this.loadMoreCursor,
})
const res = await this.rootStore.api.app.bsky.graph.getFollows(params)
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
@ -84,39 +98,24 @@ export class UserFollowsViewModel {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err ? err.toString() : ''
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch user follows', err)
}
}
// loader functions
// helper functions
// =
private async _loadMore(isRefreshing = false) {
if (!this.hasMore) {
return
}
this._xLoading(isRefreshing)
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
before: this.loadMoreCursor,
})
if (this.isRefreshing) {
this.follows = []
}
const res = await this.rootStore.api.app.bsky.graph.getFollows(params)
await this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
private _replaceAll(res: GetFollows.Response) {
this.follows = []
this._appendAll(res)
}
private async _appendAll(res: GetFollows.Response) {
private _appendAll(res: GetFollows.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.follows = this.follows.concat(res.data.follows)
this.rootStore.me.follows.hydrateProfiles(res.data.follows)
}
}

View file

@ -2,6 +2,9 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {AtUri} from '../../third-party/uri'
import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import * as apilib from 'lib/api/index'
const PAGE_SIZE = 30
@ -17,7 +20,6 @@ export class VotesViewModel {
params: GetVotes.QueryParams
hasMore = true
loadMoreCursor?: string
private _loadMorePromise: Promise<void> | undefined
// data
uri: string = ''
@ -54,17 +56,31 @@ export class VotesViewModel {
return this.loadMore(true)
}
async loadMore(isRefreshing = false) {
if (this._loadMorePromise) {
return this._loadMorePromise
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
if (!this.resolvedUri) {
await this._resolveUri()
this._xLoading(replace)
try {
if (!this.resolvedUri) {
await this._resolveUri()
}
const params = Object.assign({}, this.params, {
uri: this.resolvedUri,
limit: PAGE_SIZE,
before: replace ? undefined : this.loadMoreCursor,
})
const res = await this.rootStore.api.app.bsky.feed.getVotes(params)
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
this._loadMorePromise = this._loadMore(isRefreshing)
await this._loadMorePromise
this._loadMorePromise = undefined
}
})
// state transitions
// =
@ -79,20 +95,20 @@ export class VotesViewModel {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err ? err.toString() : ''
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch votes', err)
}
}
// loader functions
// helper functions
// =
private async _resolveUri() {
const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) {
try {
urip.host = await this.rootStore.resolveName(urip.host)
urip.host = await apilib.resolveName(this.rootStore, urip.host)
} catch (e: any) {
this.error = e.toString()
}
@ -102,23 +118,9 @@ export class VotesViewModel {
})
}
private async _loadMore(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const params = Object.assign({}, this.params, {
uri: this.resolvedUri,
limit: PAGE_SIZE,
before: this.loadMoreCursor,
})
if (this.isRefreshing) {
this.votes = []
}
const res = await this.rootStore.api.app.bsky.feed.getVotes(params)
this._appendAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
private _replaceAll(res: GetVotes.Response) {
this.votes = []
this._appendAll(res)
}
private _appendAll(res: GetVotes.Response) {

View file

@ -5,9 +5,9 @@ import {
StyleSheet,
useWindowDimensions,
} from 'react-native'
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from '../util/text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
interface AutocompleteItem {
handle: string

View file

@ -3,10 +3,12 @@ import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
KeyboardAvoidingView,
NativeSyntheticEvent,
Platform,
SafeAreaView,
ScrollView,
StyleSheet,
TextInputSelectionChangeEventData,
TouchableOpacity,
TouchableWithoutFeedback,
View,
@ -16,8 +18,9 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
// import {useAnalytics} from '@segment/analytics-react-native' TODO
import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view'
import {useAnalytics} from 'lib/analytics'
import _isEqual from 'lodash.isequal'
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
import {Autocomplete} from './Autocomplete'
import {ExternalEmbed} from './ExternalEmbed'
import {Text} from '../util/text/Text'
@ -26,24 +29,27 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
import {CharProgress} from './char-progress/CharProgress'
import {TextLink} from '../util/Link'
import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api'
import {ComposerOpts} from '../../../state/models/shell-ui'
import {s, colors, gradients} from '../../lib/styles'
import {
detectLinkables,
extractEntities,
cleanError,
} from '../../../lib/strings'
import {getLinkMeta} from '../../../lib/link-meta'
import {downloadAndResize} from '../../../lib/images'
import {useStores} from 'state/index'
import * as apilib from 'lib/api/index'
import {ComposerOpts} from 'state/models/shell-ui'
import {s, colors, gradients} from 'lib/styles'
import {cleanError} from 'lib/strings/errors'
import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
import {getLinkMeta} from 'lib/link-meta/link-meta'
import {downloadAndResize} from 'lib/images'
import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker'
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
import {SelectedPhoto} from './SelectedPhoto'
import {usePalette} from '../../lib/hooks/usePalette'
import {usePalette} from 'lib/hooks/usePalette'
const MAX_TEXT_LENGTH = 256
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
interface Selection {
start: number
end: number
}
export const ComposePost = observer(function ComposePost({
replyTo,
imagesOpen,
@ -55,10 +61,11 @@ export const ComposePost = observer(function ComposePost({
onPost?: ComposerOpts['onPost']
onClose: () => void
}) {
// const {track} = useAnalytics() TODO
const {track, screen} = useAnalytics()
const pal = usePalette('default')
const store = useStores()
const textInput = useRef<TextInputRef>(null)
const textInputSelection = useRef<Selection>({start: 0, end: 0})
const [isProcessing, setIsProcessing] = useState(false)
const [processingState, setProcessingState] = useState('')
const [error, setError] = useState('')
@ -66,7 +73,9 @@ export const ComposePost = observer(function ComposePost({
const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
undefined,
)
const [attemptedExtLinks, setAttemptedExtLinks] = useState<string[]>([])
const [suggestedExtLinks, setSuggestedExtLinks] = useState<Set<string>>(
new Set(),
)
const [isSelectingPhotos, setIsSelectingPhotos] = useState(
imagesOpen || false,
)
@ -117,10 +126,10 @@ export const ComposePost = observer(function ComposePost({
if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
downloadAndResize({
uri: extLink.meta.image,
width: 250,
height: 250,
width: 2000,
height: 2000,
mode: 'contain',
maxSize: 100000,
maxSize: 1000000,
timeout: 15e3,
})
.catch(() => undefined)
@ -166,6 +175,7 @@ export const ComposePost = observer(function ComposePost({
textInput.current?.focus()
}
const onPressSelectPhotos = () => {
track('ComposePost:SelectPhotos')
if (isSelectingPhotos) {
setIsSelectingPhotos(false)
} else if (selectedPhotos.length < 4) {
@ -173,35 +183,31 @@ export const ComposePost = observer(function ComposePost({
}
}
const onSelectPhotos = (photos: string[]) => {
track('ComposePost:SelectPhotos:Done')
setSelectedPhotos(photos)
if (photos.length >= 4) {
setIsSelectingPhotos(false)
}
}
const onPressAddLinkCard = (uri: string) => {
setExtLink({uri, isLoading: true})
}
const onChangeText = (newText: string) => {
setText(newText)
const prefix = extractTextAutocompletePrefix(newText)
if (typeof prefix === 'string') {
const prefix = getMentionAt(newText, textInputSelection.current?.start || 0)
if (prefix) {
autocompleteView.setActive(true)
autocompleteView.setPrefix(prefix)
autocompleteView.setPrefix(prefix.value)
} else {
autocompleteView.setActive(false)
}
if (!extLink && /\s$/.test(newText)) {
const ents = extractEntities(newText)
const entLink = ents
?.filter(
ent => ent.type === 'link' && !attemptedExtLinks.includes(ent.value),
)
.pop() // use last
if (entLink) {
setExtLink({
uri: entLink.value,
isLoading: true,
})
setAttemptedExtLinks([...attemptedExtLinks, entLink.value])
if (!extLink) {
const ents = extractEntities(newText)?.filter(ent => ent.type === 'link')
const set = new Set(ents ? ents.map(e => e.value) : [])
if (!_isEqual(set, suggestedExtLinks)) {
setSuggestedExtLinks(set)
}
}
}
@ -218,6 +224,16 @@ export const ComposePost = observer(function ComposePost({
onSelectPhotos([...selectedPhotos, finalImgPath])
}
}
const onSelectionChange = (
evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>,
) => {
// NOTE we track the input selection using a ref to avoid excessive renders -prf
textInputSelection.current = evt.nativeEvent.selection
}
const onSelectAutocompleteItem = (item: string) => {
setText(insertMentionAt(text, textInputSelection.current?.start || 0, item))
autocompleteView.setActive(false)
}
const onPressCancel = () => hackfixOnClose()
const onPressPublish = async () => {
if (isProcessing) {
@ -242,11 +258,15 @@ export const ComposePost = observer(function ComposePost({
autocompleteView.knownHandles,
setProcessingState,
)
// TODO
// track('Create Post', {
// imageCount: selectedPhotos.length,
// })
track('Create Post', {
imageCount: selectedPhotos.length,
})
} catch (e: any) {
setExtLink({
...extLink,
isLoading: true,
localThumb: undefined,
} as apilib.ExternalEmbedDraft)
setError(cleanError(e.message))
setIsProcessing(false)
return
@ -256,10 +276,6 @@ export const ComposePost = observer(function ComposePost({
hackfixOnClose()
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
}
const onSelectAutocompleteItem = (item: string) => {
setText(replaceTextAutocompletePrefix(text, item))
autocompleteView.setActive(false)
}
const canPost = text.length <= MAX_TEXT_LENGTH
@ -386,6 +402,7 @@ export const ComposePost = observer(function ComposePost({
innerRef={textInput}
onChangeText={(str: string) => onChangeText(str)}
onPaste={onPaste}
onSelectionChange={onSelectionChange}
placeholder={selectTextInputPlaceholder}
style={[
pal.text,
@ -406,12 +423,27 @@ export const ComposePost = observer(function ComposePost({
/>
)}
</ScrollView>
{isSelectingPhotos && selectedPhotos.length < 4 && (
{isSelectingPhotos && selectedPhotos.length < 4 ? (
<PhotoCarouselPicker
selectedPhotos={selectedPhotos}
onSelectPhotos={onSelectPhotos}
/>
)}
) : !extLink &&
selectedPhotos.length === 0 &&
suggestedExtLinks.size > 0 ? (
<View style={s.mb5}>
{Array.from(suggestedExtLinks).map(url => (
<TouchableOpacity
key={`suggested-${url}`}
style={[pal.borderDark, styles.addExtLinkBtn]}
onPress={() => onPressAddLinkCard(url)}>
<Text>
Add link card: <Text style={pal.link}>{url}</Text>
</Text>
</TouchableOpacity>
))}
</View>
) : null}
<View style={[pal.border, styles.bottomBar]}>
<TouchableOpacity
testID="composerSelectPhotosButton"
@ -442,18 +474,6 @@ export const ComposePost = observer(function ComposePost({
)
})
const atPrefixRegex = /@([a-z0-9.]*)$/i
function extractTextAutocompletePrefix(text: string) {
const match = atPrefixRegex.exec(text)
if (match) {
return match[1]
}
return undefined
}
function replaceTextAutocompletePrefix(text: string, item: string) {
return text.replace(atPrefixRegex, `@${item} `)
}
const styles = StyleSheet.create({
outer: {
flexDirection: 'column',
@ -532,6 +552,13 @@ const styles = StyleSheet.create({
paddingLeft: 13,
paddingRight: 8,
},
addExtLinkBtn: {
borderWidth: 1,
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 12,
marginBottom: 4,
},
bottomBar: {
flexDirection: 'row',
paddingVertical: 10,

View file

@ -7,12 +7,11 @@ import {
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {BlurView} from '../util/BlurView'
import LinearGradient from 'react-native-linear-gradient'
import {AutoSizedImage} from '../util/images/AutoSizedImage'
import {Text} from '../util/text/Text'
import {s, gradients} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
import {ExternalEmbedDraft} from '../../../state/lib/api'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {ExternalEmbedDraft} from 'lib/api/index'
export const ExternalEmbed = ({
link,
@ -30,31 +29,12 @@ export const ExternalEmbed = ({
<View style={[styles.outer, pal.view, pal.border]}>
{link.isLoading ? (
<View
style={[
styles.image,
styles.imageFallback,
{backgroundColor: pal.colors.backgroundLight},
]}>
style={[styles.image, {backgroundColor: pal.colors.backgroundLight}]}>
<ActivityIndicator size="large" style={styles.spinner} />
</View>
) : link.localThumb ? (
<AutoSizedImage
uri={link.localThumb.path}
containerStyle={styles.image}
/>
) : (
<LinearGradient
colors={[gradients.blueDark.start, gradients.blueDark.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.image, styles.imageFallback]}
/>
)}
<TouchableWithoutFeedback onPress={onRemove}>
<BlurView style={styles.removeBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</BlurView>
</TouchableWithoutFeedback>
<AutoSizedImage uri={link.localThumb.path} style={styles.image} />
) : undefined}
<View style={styles.inner}>
{!!link.meta?.title && (
<Text type="sm-bold" numberOfLines={2} style={[pal.text]}>
@ -81,6 +61,11 @@ export const ExternalEmbed = ({
</Text>
)}
</View>
<TouchableWithoutFeedback onPress={onRemove}>
<BlurView style={styles.removeBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</BlurView>
</TouchableWithoutFeedback>
</View>
)
}
@ -98,10 +83,7 @@ const styles = StyleSheet.create({
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
width: '100%',
height: 200,
},
imageFallback: {
height: 160,
maxHeight: 200,
},
removeBtn: {
position: 'absolute',

View file

@ -2,7 +2,7 @@ import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
import {usePalette} from 'lib/hooks/usePalette'
export function ComposePrompt({
text = "What's up?",

View file

@ -1,7 +1,8 @@
import React, {useCallback} from 'react'
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors} from '../../lib/styles'
import Image from 'view/com/util/images/Image'
import {colors} from 'lib/styles'
export const SelectedPhoto = ({
selectedPhotos,

View file

@ -5,7 +5,7 @@ import {Text} from '../../util/text/Text'
import ProgressCircle from 'react-native-progress/Circle'
// @ts-ignore no type definition -prf
import ProgressPie from 'react-native-progress/Pie'
import {s, colors} from '../../../lib/styles'
import {s, colors} from 'lib/styles'
const MAX_TEXT_LENGTH = 256
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH

View file

@ -1,7 +1,7 @@
import React from 'react'
import {View} from 'react-native'
import {Text} from '../../util/text/Text'
import {s} from '../../../lib/styles'
import {s} from 'lib/styles'
const MAX_TEXT_LENGTH = 256
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH

View file

@ -4,6 +4,7 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useAnalytics} from 'lib/analytics'
import {
openPicker,
openCamera,
@ -12,18 +13,26 @@ import {
import {
UserLocalPhotosModel,
PhotoIdentifier,
} from '../../../../state/models/user-local-photos'
import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
import {usePalette} from '../../../lib/hooks/usePalette'
import {useStores, RootStoreModel} from '../../../../state'
} from 'state/models/user-local-photos'
import {
compressIfNeeded,
moveToPremanantPath,
scaleDownDimensions,
} from 'lib/images'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores, RootStoreModel} from 'state/index'
import {
requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded,
} from 'lib/permissions'
const MAX_WIDTH = 1000
const MAX_HEIGHT = 1000
const MAX_SIZE = 300000
const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const MAX_SIZE = 1000000
const IMAGE_PARAMS = {
width: 1000,
height: 1000,
width: 2000,
height: 2000,
freeStyleCropEnabled: true,
}
@ -46,8 +55,10 @@ export async function cropPhoto(
width,
height,
})
const img = await compressIfNeeded(cropperRes, MAX_SIZE)
return img.path
const permanentPath = await moveToPremanantPath(img.path)
return permanentPath
}
export const PhotoCarouselPicker = ({
@ -57,24 +68,28 @@ export const PhotoCarouselPicker = ({
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) => {
const {track} = useAnalytics()
const pal = usePalette('default')
const store = useStores()
const [localPhotos, setLocalPhotos] = React.useState<
UserLocalPhotosModel | undefined
>(undefined)
const [isSetup, setIsSetup] = React.useState<boolean>(false)
const localPhotos = React.useMemo<UserLocalPhotosModel>(
() => new UserLocalPhotosModel(store),
[store],
)
// initial setup
React.useEffect(() => {
const photos = new UserLocalPhotosModel(store)
photos.setup().then(() => {
if (photos.photos) {
setLocalPhotos(photos)
}
// initial setup
localPhotos.setup().then(() => {
setIsSetup(true)
})
}, [store])
}, [localPhotos])
const handleOpenCamera = useCallback(async () => {
try {
if (!(await requestCameraAccessIfNeeded())) {
return
}
const cameraRes = await openCamera(store, {
mediaType: 'photo',
...IMAGE_PARAMS,
@ -89,6 +104,7 @@ export const PhotoCarouselPicker = ({
const handleSelectPhoto = useCallback(
async (item: PhotoIdentifier) => {
track('PhotoCarouselPicker:PhotoSelected')
try {
const imgPath = await cropPhoto(
store,
@ -102,37 +118,41 @@ export const PhotoCarouselPicker = ({
store.log.warn('Error selecting photo', err)
}
},
[store, selectedPhotos, onSelectPhotos],
[track, store, onSelectPhotos, selectedPhotos],
)
const handleOpenGallery = useCallback(() => {
openPicker(store, {
const handleOpenGallery = useCallback(async () => {
track('PhotoCarouselPicker:GalleryOpened')
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
multiple: true,
maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo',
}).then(async items => {
const result = []
for (const image of items) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path: image.path,
freeStyleCropEnabled: true,
width,
height,
})
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
result.push(finalImg.path)
}
onSelectPhotos([...selectedPhotos, ...result])
})
}, [store, selectedPhotos, onSelectPhotos])
const result = []
for (const image of items) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path: image.path,
...IMAGE_PARAMS,
width,
height,
})
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
const permanentPath = await moveToPremanantPath(finalImg.path)
result.push(permanentPath)
}
onSelectPhotos([...selectedPhotos, ...result])
}, [track, store, selectedPhotos, onSelectPhotos])
return (
<ScrollView
@ -161,7 +181,7 @@ export const PhotoCarouselPicker = ({
size={24}
/>
</TouchableOpacity>
{localPhotos != null &&
{isSetup &&
localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
<TouchableOpacity
testID="openSelectPhotoButton"

View file

@ -9,9 +9,9 @@ import {
openCamera,
openCropper,
} from '../../util/images/image-crop-picker/ImageCropPicker'
import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
import {usePalette} from '../../../lib/hooks/usePalette'
import {useStores, RootStoreModel} from '../../../../state'
import {compressIfNeeded, scaleDownDimensions} from 'lib/images'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores, RootStoreModel} from 'state/index'
const MAX_WIDTH = 1000
const MAX_HEIGHT = 1000

View file

@ -1,10 +1,15 @@
import React from 'react'
import {StyleProp, TextStyle} from 'react-native'
import {
NativeSyntheticEvent,
StyleProp,
TextInputSelectionChangeEventData,
TextStyle,
} from 'react-native'
import PasteInput, {
PastedFile,
PasteInputRef,
} from '@mattermost/react-native-paste-input'
import {usePalette} from '../../../lib/hooks/usePalette'
import {usePalette} from 'lib/hooks/usePalette'
export type TextInputRef = PasteInputRef
@ -14,6 +19,9 @@ interface TextInputProps {
placeholder: string
style: StyleProp<TextStyle>
onChangeText: (str: string) => void
onSelectionChange?:
| ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
| undefined
onPaste: (err: string | undefined, uris: string[]) => void
}
@ -23,6 +31,7 @@ export function TextInput({
placeholder,
style,
onChangeText,
onSelectionChange,
onPaste,
children,
}: React.PropsWithChildren<TextInputProps>) {
@ -44,6 +53,7 @@ export function TextInput({
multiline
scrollEnabled
onChangeText={(str: string) => onChangeText(str)}
onSelectionChange={onSelectionChange}
onPaste={onPasteInner}
placeholder={placeholder}
placeholderTextColor={pal.colors.textLight}

View file

@ -1,12 +1,14 @@
import React from 'react'
import {
NativeSyntheticEvent,
StyleProp,
StyleSheet,
TextInput as RNTextInput,
TextInputSelectionChangeEventData,
TextStyle,
} from 'react-native'
import {usePalette} from '../../../lib/hooks/usePalette'
import {addStyle} from '../../../lib/addStyle'
import {usePalette} from 'lib/hooks/usePalette'
import {addStyle} from 'lib/styles'
export type TextInputRef = RNTextInput
@ -16,6 +18,9 @@ interface TextInputProps {
placeholder: string
style: StyleProp<TextStyle>
onChangeText: (str: string) => void
onSelectionChange?:
| ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
| undefined
onPaste: (err: string | undefined, uris: string[]) => void
}
@ -25,6 +30,7 @@ export function TextInput({
placeholder,
style,
onChangeText,
onSelectionChange,
children,
}: React.PropsWithChildren<TextInputProps>) {
const pal = usePalette('default')
@ -36,6 +42,7 @@ export function TextInput({
multiline
scrollEnabled
onChangeText={(str: string) => onChangeText(str)}
onSelectionChange={onSelectionChange}
placeholder={placeholder}
placeholderTextColor={pal.colors.textLight}
style={style}>

View file

@ -8,19 +8,18 @@ import {
import LinearGradient from 'react-native-linear-gradient'
import {observer} from 'mobx-react-lite'
import _omit from 'lodash.omit'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import * as Toast from '../util/Toast'
import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api'
import {useStores} from 'state/index'
import * as apilib from 'lib/api/index'
import {
SuggestedActorsViewModel,
SuggestedActor,
} from '../../../state/models/suggested-actors-view'
import {s, gradients} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
} from 'state/models/suggested-actors-view'
import {s, gradients} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
export const LiteSuggestedFollows = observer(() => {
const store = useStores()

View file

@ -1,51 +1,28 @@
import React, {useEffect, useState} from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {observer} from 'mobx-react-lite'
import _omit from 'lodash.omit'
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {CenteredView, FlatList} from '../util/Views'
import {observer} from 'mobx-react-lite'
import {ErrorScreen} from '../util/error/ErrorScreen'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import * as Toast from '../util/Toast'
import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {useStores} from 'state/index'
import {
SuggestedActorsViewModel,
SuggestedActor,
} from '../../../state/models/suggested-actors-view'
import {s, gradients} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
} from 'state/models/suggested-actors-view'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
export const SuggestedFollows = observer(
({
onNoSuggestions,
asLinks,
}: {
onNoSuggestions?: () => void
asLinks?: boolean
}) => {
({onNoSuggestions}: {onNoSuggestions?: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const [follows, setFollows] = useState<Record<string, string>>({})
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const view = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store),
[store],
)
useEffect(() => {
React.useEffect(() => {
view
.loadMore()
.catch((err: any) =>
@ -53,7 +30,7 @@ export const SuggestedFollows = observer(
)
}, [view, store.log])
useEffect(() => {
React.useEffect(() => {
if (!view.isLoading && !view.hasError && !view.hasContent) {
onNoSuggestions?.()
}
@ -74,46 +51,16 @@ export const SuggestedFollows = observer(
)
}
const onPressFollow = async (item: SuggestedActor) => {
try {
const res = await apilib.follow(store, item.did, item.declaration.cid)
setFollows({[item.did]: res.uri, ...follows})
} catch (e: any) {
store.log.error('Failed fo create follow', e)
Toast.show('An issue occurred, please try again.')
}
}
const onPressUnfollow = async (item: SuggestedActor) => {
try {
await apilib.unfollow(store, follows[item.did])
setFollows(_omit(follows, [item.did]))
} catch (e: any) {
store.log.error('Failed fo delete follow', e)
Toast.show('An issue occurred, please try again.')
}
}
const renderItem = ({item}: {item: SuggestedActor}) => {
if (asLinks) {
return (
<Link
href={`/profile/${item.handle}`}
title={item.displayName || item.handle}>
<User
item={item}
follow={follows[item.did]}
onPressFollow={onPressFollow}
onPressUnfollow={onPressUnfollow}
/>
</Link>
)
}
return (
<User
item={item}
follow={follows[item.did]}
onPressFollow={onPressFollow}
onPressUnfollow={onPressUnfollow}
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
/>
)
}
@ -146,7 +93,6 @@ export const SuggestedFollows = observer(
</View>
)}
contentContainerStyle={s.contentContainer}
style={s.flex1}
/>
</View>
)}
@ -155,128 +101,16 @@ export const SuggestedFollows = observer(
},
)
const User = ({
item,
follow,
onPressFollow,
onPressUnfollow,
}: {
item: SuggestedActor
follow: string | undefined
onPressFollow: (item: SuggestedActor) => void
onPressUnfollow: (item: SuggestedActor) => void
}) => {
const pal = usePalette('default')
return (
<View style={[styles.actor, pal.view, pal.border]}>
<View style={styles.actorMeta}>
<View style={styles.actorAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.actorContent}>
<Text type="title-sm" style={pal.text} numberOfLines={1}>
{item.displayName || item.handle}
</Text>
<Text style={pal.textLight} numberOfLines={1}>
@{item.handle}
</Text>
</View>
<View style={styles.actorBtn}>
{follow ? (
<TouchableOpacity onPress={() => onPressUnfollow(item)}>
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<Text type="button" style={pal.text}>
Unfollow
</Text>
</View>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={() => onPressFollow(item)}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn, styles.gradientBtn]}>
<FontAwesomeIcon
icon="plus"
style={[s.white as FontAwesomeIconStyle, s.mr5]}
size={15}
/>
<Text style={[s.white, s.fw600, s.f15]}>Follow</Text>
</LinearGradient>
</TouchableOpacity>
)}
</View>
</View>
{item.description ? (
<View style={styles.actorDetails}>
<Text style={pal.text} numberOfLines={4}>
{item.description}
</Text>
</View>
) : undefined}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
height: '100%',
},
suggestionsContainer: {
flex: 1,
height: '100%',
},
footer: {
height: 200,
paddingTop: 20,
},
actor: {
borderTopWidth: 1,
},
actorMeta: {
flexDirection: 'row',
},
actorAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
actorContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
},
actorBtn: {
paddingRight: 10,
paddingTop: 10,
},
actorDetails: {
paddingLeft: 60,
paddingRight: 10,
paddingBottom: 10,
},
gradientBtn: {
paddingHorizontal: 24,
paddingVertical: 6,
},
secondaryBtn: {
paddingHorizontal: 14,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
marginLeft: 6,
},
})

View file

@ -0,0 +1,66 @@
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {SuggestedPostsView} from 'state/models/suggested-posts-view'
import {s} from 'lib/styles'
import {FeedItem as Post} from '../posts/FeedItem'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
export const SuggestedPosts = observer(() => {
const pal = usePalette('default')
const store = useStores()
const suggestedPostsView = React.useMemo<SuggestedPostsView>(
() => new SuggestedPostsView(store),
[store],
)
React.useEffect(() => {
if (!suggestedPostsView.hasLoaded) {
suggestedPostsView.setup()
}
}, [store, suggestedPostsView])
return (
<>
{(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && (
<Text type="title" style={[styles.heading, pal.text]}>
Recently, on Bluesky...
</Text>
)}
{suggestedPostsView.hasContent && (
<>
<View style={[pal.border, styles.bottomBorder]}>
{suggestedPostsView.posts.map(item => (
<Post item={item} key={item._reactKey} />
))}
</View>
</>
)}
{suggestedPostsView.isLoading && (
<View style={s.mt10}>
<ActivityIndicator />
</View>
)}
</>
)
})
const styles = StyleSheet.create({
heading: {
fontWeight: 'bold',
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 8,
},
bottomBorder: {
borderBottomWidth: 1,
},
loadMore: {
paddingLeft: 12,
paddingVertical: 10,
},
})

View file

@ -0,0 +1,89 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {SuggestedActorsViewModel} from 'state/models/suggested-actors-view'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {Text} from '../util/text/Text'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
export const WhoToFollow = observer(() => {
const pal = usePalette('default')
const store = useStores()
const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store, {pageSize: 5}),
[store],
)
React.useEffect(() => {
suggestedActorsView.loadMore(true)
}, [store, suggestedActorsView])
const onPressLoadMoreSuggestedActors = () => {
suggestedActorsView.loadMore()
}
return (
<>
{(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && (
<Text type="title" style={[styles.heading, pal.text]}>
Who to follow
</Text>
)}
{suggestedActorsView.hasContent && (
<>
<View style={[pal.border, styles.bottomBorder]}>
{suggestedActorsView.suggestions.map(item => (
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
/>
))}
</View>
{!suggestedActorsView.isLoading && suggestedActorsView.hasMore && (
<TouchableOpacity
onPress={onPressLoadMoreSuggestedActors}
style={styles.loadMore}>
<Text type="lg" style={pal.link}>
Show more
</Text>
</TouchableOpacity>
)}
</>
)}
{suggestedActorsView.isLoading && (
<View style={s.mt10}>
<ActivityIndicator />
</View>
)}
</>
)
})
const styles = StyleSheet.create({
heading: {
fontWeight: 'bold',
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 8,
},
bottomBorder: {
borderBottomWidth: 1,
},
loadMore: {
paddingLeft: 16,
paddingVertical: 12,
},
})

View file

@ -86,12 +86,18 @@ function ImageViewing({
[toggleBarsVisible],
)
const onLayout = useCallback(() => {
if (imageIndex) {
imageList.current?.scrollToIndex({index: imageIndex, animated: false})
}
}, [imageList, imageIndex])
if (!visible) {
return null
}
return (
<View style={styles.screen}>
<View style={styles.screen} onLayout={onLayout}>
<Modal />
<View style={[styles.container, {opacity, backgroundColor}]}>
<Animated.View style={[styles.header, {transform: headerTransform}]}>
@ -108,12 +114,8 @@ function ImageViewing({
data={images}
horizontal
pagingEnabled
windowSize={2}
initialNumToRender={1}
maxToRenderPerBatch={1}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
initialScrollIndex={imageIndex}
getItem={(_, index) => images[index]}
getItemCount={() => images.length}
getItemLayout={(_, index) => ({

View file

@ -2,9 +2,9 @@ import React from 'react'
import {View} from 'react-native'
import {observer} from 'mobx-react-lite'
import ImageView from './ImageViewing'
import {useStores} from '../../../state'
import * as models from '../../../state/models/shell-ui'
import {saveImageModal} from '../../../lib/images'
import {useStores} from 'state/index'
import * as models from 'state/models/shell-ui'
import {saveImageModal} from 'lib/images'
import {ImageSource} from './ImageViewing/@types'
export const Lightbox = observer(function Lightbox() {

View file

@ -8,9 +8,9 @@ import {
} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useStores} from '../../../state'
import * as models from '../../../state/models/shell-ui'
import {colors} from '../../lib/styles'
import {useStores} from 'state/index'
import * as models from 'state/models/shell-ui'
import {colors} from 'lib/styles'
interface Img {
uri: string

View file

@ -15,24 +15,22 @@ import {
} from '@fortawesome/react-native-fontawesome'
import {ComAtprotoAccountCreate} from '@atproto/api'
import * as EmailValidator from 'email-validator'
// import {useAnalytics} from '@segment/analytics-react-native' TODO
import {useAnalytics} from 'lib/analytics'
import {LogoTextHero} from './Logo'
import {Picker} from '../util/Picker'
import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text'
import {s, colors} from '../../lib/styles'
import {
makeValidHandle,
createFullHandle,
toNiceDomain,
} from '../../../lib/strings'
import {useStores, DEFAULT_SERVICE} from '../../../state'
import {ServiceDescription} from '../../../state/models/session'
import {ServerInputModal} from '../../../state/models/shell-ui'
import {usePalette} from '../../lib/hooks/usePalette'
import {s, colors} from 'lib/styles'
import {makeValidHandle, createFullHandle} from 'lib/strings/handles'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {useStores, DEFAULT_SERVICE} from 'state/index'
import {ServiceDescription} from 'state/models/session'
import {ServerInputModal} from 'state/models/shell-ui'
import {usePalette} from 'lib/hooks/usePalette'
import {cleanError} from 'lib/strings/errors'
export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
// const {track} = useAnalytics() TODO
const {track, screen} = useAnalytics()
const pal = usePalette('default')
const store = useStores()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
@ -49,6 +47,10 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
const [handle, setHandle] = useState<string>('')
const [is13, setIs13] = useState<boolean>(false)
useEffect(() => {
screen('CreateAccount')
}, [screen])
useEffect(() => {
let aborted = false
setError('')
@ -109,7 +111,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
password,
inviteCode,
})
// track('Create Account') TODO
track('Create Account')
} catch (e: any) {
let errMsg = e.toString()
if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) {
@ -118,7 +120,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
}
store.log.error('Failed to create account', e)
setIsProcessing(false)
setError(errMsg.replace(/^Error:/, ''))
setError(cleanError(errMsg))
}
}

View file

@ -1,7 +1,7 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {s, gradients} from '../../lib/styles'
import {s, gradients} from 'lib/styles'
import {Text} from '../util/text/Text'
export const LogoTextHero = () => {

View file

@ -13,19 +13,21 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import * as EmailValidator from 'email-validator'
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
// import {useAnalytics} from '@segment/analytics-react-native' TODO
import AtpAgent from '@atproto/api'
import {useAnalytics} from 'lib/analytics'
import {LogoTextHero} from './Logo'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import {s, colors} from '../../lib/styles'
import {createFullHandle, toNiceDomain} from '../../../lib/strings'
import {useStores, RootStoreModel, DEFAULT_SERVICE} from '../../../state'
import {ServiceDescription} from '../../../state/models/session'
import {ServerInputModal} from '../../../state/models/shell-ui'
import {AccountData} from '../../../state/models/session'
import {isNetworkError} from '../../../lib/errors'
import {usePalette} from '../../lib/hooks/usePalette'
import {s, colors} from 'lib/styles'
import {createFullHandle} from 'lib/strings/handles'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index'
import {ServiceDescription} from 'state/models/session'
import {ServerInputModal} from 'state/models/shell-ui'
import {AccountData} from 'state/models/session'
import {isNetworkError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette'
import {cleanError} from 'lib/strings/errors'
enum Forms {
Login,
@ -38,6 +40,7 @@ enum Forms {
export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
const [error, setError] = useState<string>('')
const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
@ -91,6 +94,10 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
}, [store.session, store.log, serviceUrl, retryDescribeTrigger])
const onPressRetryConnect = () => setRetryDescribeTrigger({})
const onPressForgotPassword = () => {
track('Signin:PressedForgotPassword')
setCurrentForm(Forms.ForgotPassword)
}
return (
<KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}>
@ -104,7 +111,7 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
setError={setError}
setServiceUrl={setServiceUrl}
onPressBack={onPressBack}
onPressForgotPassword={gotoForm(Forms.ForgotPassword)}
onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={onPressRetryConnect}
/>
) : undefined}
@ -153,15 +160,19 @@ const ChooseAccountForm = ({
onSelectAccount: (account?: AccountData) => void
onPressBack: () => void
}) => {
// const {track} = useAnalytics() TODO
const {track, screen} = useAnalytics()
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = React.useState(false)
// React.useEffect(() => {
screen('Choose Account')
// }, [screen])
const onTryAccount = async (account: AccountData) => {
if (account.accessJwt && account.refreshJwt) {
setIsProcessing(true)
if (await store.session.resumeSession(account)) {
// track('Sign In', {resumedSession: true}) TODO
track('Sign In', {resumedSession: true})
setIsProcessing(false)
return
}
@ -261,15 +272,16 @@ const LoginForm = ({
onPressBack: () => void
onPressForgotPassword: () => void
}) => {
// const {track} = useAnalytics() TODO
const {track} = useAnalytics()
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [handle, setHandle] = useState<string>(initialHandle)
const [identifier, setIdentifier] = useState<string>(initialHandle)
const [password, setPassword] = useState<string>('')
const onPressSelectService = () => {
store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl))
Keyboard.dismiss()
track('Signin:PressedSelectService')
}
const onPressNext = async () => {
@ -278,20 +290,21 @@ const LoginForm = ({
try {
// try to guess the handle if the user just gave their own username
let fullHandle = handle
let fullIdent = identifier
if (
!identifier.includes('@') && // not an email
serviceDescription &&
serviceDescription.availableUserDomains.length > 0
) {
let matched = false
for (const domain of serviceDescription.availableUserDomains) {
if (fullHandle.endsWith(domain)) {
if (fullIdent.endsWith(domain)) {
matched = true
}
}
if (!matched) {
fullHandle = createFullHandle(
handle,
fullIdent = createFullHandle(
identifier,
serviceDescription.availableUserDomains[0],
)
}
@ -299,10 +312,10 @@ const LoginForm = ({
await store.session.login({
service: serviceUrl,
handle: fullHandle,
identifier: fullIdent,
password,
})
// track('Sign In', {resumedSession: false}) TODO
track('Sign In', {resumedSession: false})
} catch (e: any) {
const errMsg = e.toString()
store.log.warn('Failed to login', e)
@ -314,12 +327,12 @@ const LoginForm = ({
'Unable to contact your service. Please check your Internet connection.',
)
} else {
setError(errMsg.replace(/^Error:/, ''))
setError(cleanError(errMsg))
}
}
}
const isReady = !!serviceDescription && !!handle && !!password
const isReady = !!serviceDescription && !!identifier && !!password
return (
<View testID="loginForm">
<LogoTextHero />
@ -361,13 +374,13 @@ const LoginForm = ({
<TextInput
testID="loginUsernameInput"
style={[pal.text, styles.textInput]}
placeholder="Username"
placeholder="Username or email address"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoFocus
autoCorrect={false}
value={handle}
onChangeText={str => setHandle((str || '').toLowerCase())}
value={identifier}
onChangeText={str => setIdentifier((str || '').toLowerCase())}
editable={!isProcessing}
/>
</View>
@ -464,6 +477,11 @@ const ForgotPasswordForm = ({
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [email, setEmail] = useState<string>('')
const {screen} = useAnalytics()
// useEffect(() => {
screen('Signin:ForgotPassword')
// }, [screen])
const onPressSelectService = () => {
store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl))
@ -478,8 +496,8 @@ const ForgotPasswordForm = ({
setIsProcessing(true)
try {
const api = AtpApi.service(serviceUrl) as SessionServiceClient
await api.com.atproto.account.requestPasswordReset({email})
const agent = new AtpAgent({service: serviceUrl})
await agent.api.com.atproto.account.requestPasswordReset({email})
onEmailSent()
} catch (e: any) {
const errMsg = e.toString()
@ -490,7 +508,7 @@ const ForgotPasswordForm = ({
'Unable to contact your service. Please check your Internet connection.',
)
} else {
setError(errMsg.replace(/^Error:/, ''))
setError(cleanError(errMsg))
}
}
}
@ -604,6 +622,12 @@ const SetNewPasswordForm = ({
onPasswordSet: () => void
}) => {
const pal = usePalette('default')
const {screen} = useAnalytics()
// useEffect(() => {
screen('Signin:SetNewPasswordForm')
// }, [screen])
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [resetCode, setResetCode] = useState<string>('')
const [password, setPassword] = useState<string>('')
@ -613,8 +637,11 @@ const SetNewPasswordForm = ({
setIsProcessing(true)
try {
const api = AtpApi.service(serviceUrl) as SessionServiceClient
await api.com.atproto.account.resetPassword({token: resetCode, password})
const agent = new AtpAgent({service: serviceUrl})
await agent.api.com.atproto.account.resetPassword({
token: resetCode,
password,
})
onPasswordSet()
} catch (e: any) {
const errMsg = e.toString()
@ -625,7 +652,7 @@ const SetNewPasswordForm = ({
'Unable to contact your service. Please check your Internet connection.',
)
} else {
setError(errMsg.replace(/^Error:/, ''))
setError(cleanError(errMsg))
}
}
}
@ -726,6 +753,12 @@ const SetNewPasswordForm = ({
}
const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
const {screen} = useAnalytics()
// useEffect(() => {
screen('Signin:PasswordUpdatedForm')
// }, [screen])
const pal = usePalette('default')
return (
<>

View file

@ -7,9 +7,10 @@ import {
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import {s, colors, gradients} from '../../lib/styles'
import {useStores} from 'state/index'
import {s, colors, gradients} from 'lib/styles'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
export const snapPoints = ['50%']
@ -33,7 +34,7 @@ export function Component({
store.shell.closeModal()
return
} catch (e: any) {
setError(e.toString())
setError(cleanError(e))
setIsProcessing(false)
}
}

View file

@ -0,0 +1,210 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {BottomSheetTextInput} from '@gorhom/bottom-sheet'
import LinearGradient from 'react-native-linear-gradient'
import * as Toast from '../util/Toast'
import {Text} from '../util/text/Text'
import {useStores} from 'state/index'
import {s, colors, gradients} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
export const snapPoints = ['60%']
export function Component({}: {}) {
const pal = usePalette('default')
const store = useStores()
const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
const [confirmCode, setConfirmCode] = React.useState<string>('')
const [password, setPassword] = React.useState<string>('')
const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
const [error, setError] = React.useState<string>('')
const onPressSendEmail = async () => {
setError('')
setIsProcessing(true)
try {
await store.api.com.atproto.account.requestDelete()
setIsEmailSent(true)
} catch (e: any) {
setError(cleanError(e))
}
setIsProcessing(false)
}
const onPressConfirmDelete = async () => {
setError('')
setIsProcessing(true)
try {
await store.api.com.atproto.account.delete({
did: store.me.did,
password,
token: confirmCode,
})
Toast.show('Your account has been deleted')
store.nav.tab.fixedTabReset()
store.session.clear()
store.shell.closeModal()
} catch (e: any) {
setError(cleanError(e))
}
setIsProcessing(false)
}
const onCancel = () => {
store.shell.closeModal()
}
return (
<View
style={[styles.container, {backgroundColor: pal.colors.backgroundLight}]}>
<View style={[styles.innerContainer, pal.view]}>
<Text type="title-xl" style={[styles.title, pal.text]}>
Delete account
</Text>
{!isEmailSent ? (
<>
<Text type="lg" style={[styles.description, pal.text]}>
For security reasons, we'll need to send a confirmation code to
your email.
</Text>
{error ? (
<View style={s.mt10}>
<ErrorMessage message={error} />
</View>
) : undefined}
{isProcessing ? (
<View style={[styles.btn, s.mt10]}>
<ActivityIndicator />
</View>
) : (
<>
<TouchableOpacity
style={styles.mt20}
onPress={onPressSendEmail}>
<LinearGradient
colors={[
gradients.blueLight.start,
gradients.blueLight.end,
]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text type="button-lg" style={[s.white, s.bold]}>
Send email
</Text>
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, s.mt10]}
onPress={onCancel}>
<Text type="button-lg" style={pal.textLight}>
Cancel
</Text>
</TouchableOpacity>
</>
)}
</>
) : (
<>
<Text type="lg" style={styles.description}>
Check your inbox for an email with the confirmation code to enter
below:
</Text>
<BottomSheetTextInput
style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]}
placeholder="Confirmation code"
placeholderTextColor={pal.textLight.color}
value={confirmCode}
onChangeText={setConfirmCode}
/>
<Text type="lg" style={styles.description}>
Please enter your password as well:
</Text>
<BottomSheetTextInput
style={[styles.textInput, pal.borderDark, pal.text]}
placeholder="Password"
placeholderTextColor={pal.textLight.color}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? (
<View style={styles.mt20}>
<ErrorMessage message={error} />
</View>
) : undefined}
{isProcessing ? (
<View style={[styles.btn, s.mt10]}>
<ActivityIndicator />
</View>
) : (
<>
<TouchableOpacity
style={[styles.btn, styles.evilBtn, styles.mt20]}
onPress={onPressConfirmDelete}>
<Text type="button-lg" style={[s.white, s.bold]}>
Delete my account
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, s.mt10]}
onPress={onCancel}>
<Text type="button-lg" style={pal.textLight}>
Cancel
</Text>
</TouchableOpacity>
</>
)}
</>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
innerContainer: {
paddingBottom: 20,
},
title: {
textAlign: 'center',
marginTop: 12,
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 22,
marginBottom: 10,
},
mt20: {
marginTop: 20,
},
mb20: {
marginBottom: 20,
},
textInput: {
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 20,
marginHorizontal: 20,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
marginHorizontal: 20,
},
evilBtn: {
backgroundColor: colors.red4,
},
})

View file

@ -11,18 +11,17 @@ import {ScrollView, TextInput} from './util'
import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from '../../../state'
import {ProfileViewModel} from '../../../state/models/profile-view'
import {s, colors, gradients} from '../../lib/styles'
import {
enforceLen,
MAX_DISPLAY_NAME,
MAX_DESCRIPTION,
} from '../../../lib/strings'
import {isNetworkError} from '../../../lib/errors'
import {compressIfNeeded} from '../../../lib/images'
import {useStores} from 'state/index'
import {ProfileViewModel} from 'state/models/profile-view'
import {s, colors, gradients} from 'lib/styles'
import {enforceLen} from 'lib/strings/helpers'
import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants'
import {compressIfNeeded} from 'lib/images'
import {UserBanner} from '../util/UserBanner'
import {UserAvatar} from '../util/UserAvatar'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
import {cleanError, isNetworkError} from 'lib/strings/errors'
export const snapPoints = ['80%']
@ -35,6 +34,9 @@ export function Component({
}) {
const store = useStores()
const [error, setError] = useState<string>('')
const pal = usePalette('default')
const {track} = useAnalytics()
const [isProcessing, setProcessing] = useState<boolean>(false)
const [displayName, setDisplayName] = useState<string>(
profileView.displayName || '',
@ -54,24 +56,27 @@ export function Component({
store.shell.closeModal()
}
const onSelectNewAvatar = async (img: PickedMedia) => {
track('EditProfile:AvatarSelected')
try {
const finalImg = await compressIfNeeded(img, 300000)
setNewUserAvatar(finalImg)
const finalImg = await compressIfNeeded(img, 1000000)
setNewUserAvatar({mediaType: 'photo', ...finalImg})
setUserAvatar(finalImg.path)
} catch (e: any) {
setError(e.message || e.toString())
setError(cleanError(e))
}
}
const onSelectNewBanner = async (img: PickedMedia) => {
track('EditProfile:BannerSelected')
try {
const finalImg = await compressIfNeeded(img, 500000)
setNewUserBanner(finalImg)
const finalImg = await compressIfNeeded(img, 1000000)
setNewUserBanner({mediaType: 'photo', ...finalImg})
setUserBanner(finalImg.path)
} catch (e: any) {
setError(e.message || e.toString())
setError(cleanError(e))
}
}
const onPressSave = async () => {
track('EditProfile:Save')
setProcessing(true)
if (error) {
setError('')
@ -94,7 +99,7 @@ export function Component({
'Failed to save your profile. Check your internet connection and try again.',
)
} else {
setError(e.message)
setError(cleanError(e))
}
}
setProcessing(false)
@ -103,13 +108,13 @@ export function Component({
return (
<View style={s.flex1}>
<ScrollView style={styles.inner}>
<Text style={styles.title}>Edit my profile</Text>
<Text style={[styles.title, pal.text]}>Edit my profile</Text>
<View style={styles.photos}>
<UserBanner
banner={userBanner}
onSelectNewBanner={onSelectNewBanner}
/>
<View style={styles.avi}>
<View style={[styles.avi, {borderColor: pal.colors.background}]}>
<UserAvatar
size={80}
avatar={userAvatar}
@ -127,7 +132,7 @@ export function Component({
<View>
<Text style={styles.label}>Display Name</Text>
<TextInput
style={styles.textInput}
style={[styles.textInput, pal.text]}
placeholder="e.g. Alice Roberts"
placeholderTextColor={colors.gray4}
value={displayName}
@ -135,9 +140,9 @@ export function Component({
/>
</View>
<View style={s.pb10}>
<Text style={styles.label}>Description</Text>
<Text style={[styles.label, pal.text]}>Description</Text>
<TextInput
style={[styles.textArea]}
style={[styles.textArea, pal.text]}
placeholder="e.g. Artist, dog-lover, and memelord."
placeholderTextColor={colors.gray4}
multiline
@ -162,7 +167,7 @@ export function Component({
)}
<TouchableOpacity style={s.mt5} onPress={onPressCancel}>
<View style={[styles.btn]}>
<Text style={[s.black, s.bold]}>Cancel</Text>
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
</View>
</TouchableOpacity>
</ScrollView>

View file

@ -2,23 +2,26 @@ import React, {useRef, useEffect} from 'react'
import {View} from 'react-native'
import {observer} from 'mobx-react-lite'
import BottomSheet from '@gorhom/bottom-sheet'
import {useStores} from '../../../state'
import {useStores} from 'state/index'
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import * as models from '../../../state/models/shell-ui'
import * as models from 'state/models/shell-ui'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as ReportAccountModal from './ReportAccount'
import * as DeleteAccountModal from './DeleteAccount'
import {usePalette} from 'lib/hooks/usePalette'
import {StyleSheet} from 'react-native'
const CLOSED_SNAPPOINTS = ['10%']
export const Modal = observer(function Modal() {
const store = useStores()
const bottomSheetRef = useRef<BottomSheet>(null)
const pal = usePalette('default')
const onBottomSheetChange = (snapPoint: number) => {
if (snapPoint === -1) {
store.shell.closeModal()
@ -62,10 +65,21 @@ export const Modal = observer(function Modal() {
)
} else if (store.shell.activeModal?.name === 'report-post') {
snapPoints = ReportPostModal.snapPoints
element = <ReportPostModal.Component />
element = (
<ReportPostModal.Component
{...(store.shell.activeModal as models.ReportPostModal)}
/>
)
} else if (store.shell.activeModal?.name === 'report-account') {
snapPoints = ReportAccountModal.snapPoints
element = <ReportAccountModal.Component />
element = (
<ReportAccountModal.Component
{...(store.shell.activeModal as models.ReportAccountModal)}
/>
)
} else if (store.shell.activeModal?.name === 'delete-account') {
snapPoints = DeleteAccountModal.snapPoints
element = <DeleteAccountModal.Component />
} else {
element = <View />
}
@ -80,8 +94,17 @@ export const Modal = observer(function Modal() {
backdropComponent={
store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined
}
handleIndicatorStyle={{backgroundColor: pal.text.color}}
handleStyle={[styles.handle, pal.view]}
onChange={onBottomSheetChange}>
{element}
</BottomSheet>
)
})
const styles = StyleSheet.create({
handle: {
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
})

View file

@ -1,10 +1,10 @@
import React from 'react'
import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from '../../../state'
import {usePalette} from '../../lib/hooks/usePalette'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import * as models from '../../../state/models/shell-ui'
import * as models from 'state/models/shell-ui'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile'
@ -48,9 +48,17 @@ export const Modal = observer(function Modal() {
/>
)
} else if (store.shell.activeModal?.name === 'report-post') {
element = <ReportPostModal.Component />
element = (
<ReportPostModal.Component
{...(store.shell.activeModal as models.ReportPostModal)}
/>
)
} else if (store.shell.activeModal?.name === 'report-account') {
element = <ReportAccountModal.Component />
element = (
<ReportAccountModal.Component
{...(store.shell.activeModal as models.ReportAccountModal)}
/>
)
} else if (store.shell.activeModal?.name === 'crop-image') {
element = (
<CropImageModal.Component

View file

@ -5,12 +5,15 @@ import {
TouchableOpacity,
View,
} from 'react-native'
import {ComAtprotoReportReasonType} from '@atproto/api'
import LinearGradient from 'react-native-linear-gradient'
import {useStores} from '../../../state'
import {s, colors, gradients} from '../../lib/styles'
import {useStores} from 'state/index'
import {s, colors, gradients} from 'lib/styles'
import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
const ITEMS: RadioGroupItem[] = [
{key: 'spam', label: 'Spam or excessive repeat posts'},
@ -20,7 +23,7 @@ const ITEMS: RadioGroupItem[] = [
export const snapPoints = ['50%']
export function Component() {
export function Component({did}: {did: string}) {
const store = useStores()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
@ -28,13 +31,30 @@ export function Component() {
const onSelectIssue = (v: string) => setIssue(v)
const onPress = async () => {
setError('')
if (!issue) {
return
}
setIsProcessing(true)
try {
// TODO
// NOTE: we should update the lexicon of reasontype to include more options -prf
let reasonType = ComAtprotoReportReasonType.OTHER
if (issue === 'spam') {
reasonType = ComAtprotoReportReasonType.SPAM
}
const reason = ITEMS.find(item => item.key === issue)?.label || ''
await store.api.com.atproto.report.create({
reasonType,
reason,
subject: {
$type: 'com.atproto.repo.repoRef',
did,
},
})
Toast.show("Thank you for your report! We'll look into it promptly.")
store.shell.closeModal()
return
} catch (e: any) {
setError(e.toString())
setError(cleanError(e))
setIsProcessing(false)
}
}

View file

@ -5,12 +5,15 @@ import {
TouchableOpacity,
View,
} from 'react-native'
import {ComAtprotoReportReasonType} from '@atproto/api'
import LinearGradient from 'react-native-linear-gradient'
import {useStores} from '../../../state'
import {s, colors, gradients} from '../../lib/styles'
import {useStores} from 'state/index'
import {s, colors, gradients} from 'lib/styles'
import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
const ITEMS: RadioGroupItem[] = [
{key: 'spam', label: 'Spam or excessive repeat posts'},
@ -21,7 +24,13 @@ const ITEMS: RadioGroupItem[] = [
export const snapPoints = ['50%']
export function Component() {
export function Component({
postUri,
postCid,
}: {
postUri: string
postCid: string
}) {
const store = useStores()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
@ -29,13 +38,31 @@ export function Component() {
const onSelectIssue = (v: string) => setIssue(v)
const onPress = async () => {
setError('')
if (!issue) {
return
}
setIsProcessing(true)
try {
// TODO
// NOTE: we should update the lexicon of reasontype to include more options -prf
let reasonType = ComAtprotoReportReasonType.OTHER
if (issue === 'spam') {
reasonType = ComAtprotoReportReasonType.SPAM
}
const reason = ITEMS.find(item => item.key === issue)?.label || ''
await store.api.com.atproto.report.create({
reasonType,
reason,
subject: {
$type: 'com.atproto.repo.recordRef',
uri: postUri,
cid: postCid,
},
})
Toast.show("Thank you for your report! We'll look into it promptly.")
store.shell.closeModal()
return
} catch (e: any) {
setError(e.toString())
setError(cleanError(e))
setIsProcessing(false)
}
}

View file

@ -6,14 +6,10 @@ import {
} from '@fortawesome/react-native-fontawesome'
import {ScrollView, TextInput} from './util'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles'
import {
LOCAL_DEV_SERVICE,
STAGING_SERVICE,
PROD_SERVICE,
} from '../../../state/index'
import {LOGIN_INCLUDE_DEV_SERVERS} from '../../../build-flags'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
export const snapPoints = ['80%']
@ -37,6 +33,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
{LOGIN_INCLUDE_DEV_SERVERS ? (
<>
<TouchableOpacity
testID="localDevServerButton"
style={styles.btn}
onPress={() => doSelect(LOCAL_DEV_SERVICE)}>
<Text style={styles.btnText}>Local dev server</Text>

View file

@ -5,10 +5,10 @@ import {Slider} from '@miblanchard/react-native-slider'
import LinearGradient from 'react-native-linear-gradient'
import {Text} from '../../util/text/Text'
import {PickedMedia} from '../../util/images/image-crop-picker/types'
import {s, gradients} from '../../../lib/styles'
import {useStores} from '../../../../state'
import {usePalette} from '../../../lib/hooks/usePalette'
import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons'
import {s, gradients} from 'lib/styles'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons'
enum AspectRatio {
Square = 'square',

View file

@ -1,31 +1,61 @@
import React from 'react'
import React, {MutableRefObject} from 'react'
import {observer} from 'mobx-react-lite'
import {StyleSheet, View} from 'react-native'
import {CenteredView, FlatList} from '../util/Views'
import {NotificationsViewModel} from '../../../state/models/notifications-view'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {NotificationsViewModel} from 'state/models/notifications-view'
import {FeedItem} from './FeedItem'
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {EmptyState} from '../util/EmptyState'
import {OnScrollCb} from '../../lib/hooks/useOnMainScroll'
import {s} from '../../lib/styles'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
export const Feed = observer(function Feed({
view,
scrollElRef,
onPressTryAgain,
onScroll,
}: {
view: NotificationsViewModel
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void
onScroll?: OnScrollCb
}) {
const data = React.useMemo(() => {
let feedItems
if (view.hasLoaded) {
if (view.isEmpty) {
feedItems = [EMPTY_FEED_ITEM]
} else {
feedItems = view.notifications
}
}
return feedItems
}, [view.hasLoaded, view.isEmpty, view.notifications])
const onRefresh = React.useCallback(async () => {
try {
await view.refresh()
await view.markAllRead()
} catch (err) {
view.rootStore.log.error('Failed to refresh notifications feed', err)
}
}, [view])
const onEndReached = React.useCallback(async () => {
try {
await view.loadMore()
} catch (err) {
view.rootStore.log.error('Failed to load more notifications', err)
}
}, [view])
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// VirtualizedList: You have a large list that is slow to update - make sure your
// renderItem function renders components that follow React performance best practices
// like PureComponent, shouldComponentUpdate, etc
const renderItem = ({item}: {item: any}) => {
const renderItem = React.useCallback(({item}: {item: any}) => {
if (item === EMPTY_FEED_ITEM) {
return (
<EmptyState
@ -36,29 +66,20 @@ export const Feed = observer(function Feed({
)
}
return <FeedItem item={item} />
}
const onRefresh = () => {
view
.refresh()
.catch(err =>
view.rootStore.log.error('Failed to refresh notifications feed', err),
)
}
const onEndReached = () => {
view
.loadMore()
.catch(err =>
view.rootStore.log.error('Failed to load more notifications', err),
)
}
let data
if (view.hasLoaded) {
if (view.isEmpty) {
data = [EMPTY_FEED_ITEM]
} else {
data = view.notifications
}
}
}, [])
const FeedFooter = React.useCallback(
() =>
view.isLoading ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
),
[view],
)
return (
<View style={s.h100pct}>
<CenteredView>
@ -72,9 +93,11 @@ export const Feed = observer(function Feed({
</CenteredView>
{data && (
<FlatList
ref={scrollElRef}
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListFooterComponent={FeedFooter}
refreshing={view.isRefreshing}
onRefresh={onRefresh}
onEndReached={onEndReached}
@ -87,5 +110,6 @@ export const Feed = observer(function Feed({
})
const styles = StyleSheet.create({
feedFooter: {paddingTop: 20},
emptyState: {paddingVertical: 40},
})

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