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

@ -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

@ -1,79 +0,0 @@
import {sessionClient as AtpApi} from '@atproto/api'
import RNFS from 'react-native-fs'
const TIMEOUT = 10e3 // 10s
export function doPolyfill() {
AtpApi.xrpc.fetch = fetchHandler
}
interface FetchHandlerResponse {
status: number
headers: Record<string, string>
body: ArrayBuffer | undefined
}
async function fetchHandler(
reqUri: string,
reqMethod: string,
reqHeaders: Record<string, string>,
reqBody: any,
): Promise<FetchHandlerResponse> {
const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type']
if (reqMimeType && reqMimeType.startsWith('application/json')) {
reqBody = JSON.stringify(reqBody)
} else if (
typeof reqBody === 'string' &&
(reqBody.startsWith('/') || reqBody.startsWith('file:'))
) {
if (reqBody.endsWith('.jpeg') || reqBody.endsWith('.jpg')) {
// HACK
// React native has a bug that inflates the size of jpegs on upload
// we get around that by renaming the file ext to .bin
// see https://github.com/facebook/react-native/issues/27099
// -prf
const newPath = reqBody.replace(/\.jpe?g$/, '.bin')
await RNFS.moveFile(reqBody, newPath)
reqBody = newPath
}
// NOTE
// React native treats bodies with {uri: string} as file uploads to pull from cache
// -prf
reqBody = {uri: reqBody}
}
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), TIMEOUT)
const res = await fetch(reqUri, {
method: reqMethod,
headers: reqHeaders,
body: reqBody,
signal: controller.signal,
})
const resStatus = res.status
const resHeaders: Record<string, string> = {}
res.headers.forEach((value: string, key: string) => {
resHeaders[key] = value
})
const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type']
let resBody
if (resMimeType) {
if (resMimeType.startsWith('application/json')) {
resBody = await res.json()
} else if (resMimeType.startsWith('text/')) {
resBody = await res.text()
} else {
throw new Error('TODO: non-textual response body')
}
}
clearTimeout(to)
return {
status: resStatus,
headers: resHeaders,
body: resBody,
}
}

View file

@ -1,4 +0,0 @@
export function doPolyfill() {
// TODO needed? native fetch may work fine -prf
// AtpApi.xrpc.fetch = fetchHandler
}

View file

@ -1,190 +0,0 @@
/**
* 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'
export interface ExternalEmbedDraft {
uri: string
isLoading: boolean
meta?: LinkMeta
localThumb?: Image
}
export async function post(
store: RootStoreModel,
text: string,
replyTo?: string,
extLink?: ExternalEmbedDraft,
images?: string[],
knownHandles?: Set<string>,
onStateChange?: (state: string) => void,
) {
let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined
let reply
onStateChange?.('Processing...')
const entities = extractEntities(text, knownHandles)
if (entities) {
for (const ent of entities) {
if (ent.type === 'mention') {
const prof = await store.profiles.getProfile(ent.value)
ent.value = prof.data.did
}
}
}
if (images?.length) {
embed = {
$type: 'app.bsky.embed.images',
images: [],
} as AppBskyEmbedImages.Main
let i = 1
for (const image of images) {
onStateChange?.(`Uploading image #${i++}...`)
const res = await store.api.com.atproto.blob.upload(
image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding: 'image/jpeg'},
)
embed.images.push({
image: {
cid: res.data.cid,
mimeType: 'image/jpeg',
},
alt: '', // TODO supply alt text
})
}
}
if (!embed && extLink) {
let thumb
if (extLink.localThumb) {
onStateChange?.('Uploading link thumbnail...')
let encoding
if (extLink.localThumb.path.endsWith('.png')) {
encoding = 'image/png'
} else if (
extLink.localThumb.path.endsWith('.jpeg') ||
extLink.localThumb.path.endsWith('.jpg')
) {
encoding = 'image/jpeg'
} else {
store.log.warn(
'Unexpected image format for thumbnail, skipping',
extLink.localThumb.path,
)
}
if (encoding) {
const thumbUploadRes = await store.api.com.atproto.blob.upload(
extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding},
)
thumb = {
cid: thumbUploadRes.data.cid,
mimeType: encoding,
}
}
}
embed = {
$type: 'app.bsky.embed.external',
external: {
uri: extLink.uri,
title: extLink.meta?.title || '',
description: extLink.meta?.description || '',
thumb,
},
} as AppBskyEmbedExternal.Main
}
if (replyTo) {
const replyToUrip = new AtUri(replyTo)
const parentPost = await store.api.app.bsky.feed.post.get({
user: replyToUrip.host,
rkey: replyToUrip.rkey,
})
if (parentPost) {
const parentRef = {
uri: parentPost.uri,
cid: parentPost.cid,
}
reply = {
root: parentPost.value.reply?.root || parentRef,
parent: parentRef,
}
}
}
try {
onStateChange?.('Posting...')
return await store.api.app.bsky.feed.post.create(
{did: store.me.did || ''},
{
text,
reply,
embed,
entities,
createdAt: new Date().toISOString(),
},
)
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)
if (isNetworkError(e)) {
throw new Error(
'Post failed to upload. Please check your Internet connection and try again.',
)
} else {
throw e
}
}
}
export async function repost(store: RootStoreModel, uri: string, cid: string) {
return await store.api.app.bsky.feed.repost.create(
{did: store.me.did || ''},
{
subject: {uri, cid},
createdAt: new Date().toISOString(),
},
)
}
export async function unrepost(store: RootStoreModel, repostUri: string) {
const repostUrip = new AtUri(repostUri)
return await store.api.app.bsky.feed.repost.delete({
did: repostUrip.hostname,
rkey: repostUrip.rkey,
})
}
export async function follow(
store: RootStoreModel,
subjectDid: string,
subjectDeclarationCid: string,
) {
return await store.api.app.bsky.graph.follow.create(
{did: store.me.did || ''},
{
subject: {
did: subjectDid,
declarationCid: subjectDeclarationCid,
},
createdAt: new Date().toISOString(),
},
)
}
export async function unfollow(store: RootStoreModel, followUri: string) {
const followUrip = new AtUri(followUri)
return await store.api.app.bsky.graph.follow.delete({
did: followUrip.hostname,
rkey: followUrip.rkey,
})
}

View file

@ -1,18 +0,0 @@
import BackgroundFetch, {
BackgroundFetchStatus,
} from 'react-native-background-fetch'
export function configure(
handler: (taskId: string) => Promise<void>,
timeoutHandler: (taskId: string) => Promise<void>,
): Promise<BackgroundFetchStatus> {
return BackgroundFetch.configure(
{minimumFetchInterval: 15},
handler,
timeoutHandler,
)
}
export function finish(taskId: string) {
return BackgroundFetch.finish(taskId)
}

View file

@ -1,13 +0,0 @@
type BackgroundFetchStatus = 0 | 1 | 2
export async function configure(
_handler: (taskId: string) => Promise<void>,
_timeoutHandler: (taskId: string) => Promise<void>,
): Promise<BackgroundFetchStatus> {
// TODO
return 0
}
export function finish(_taskId: string) {
// TODO
}

View file

@ -1,52 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
export async function loadString(key: string): Promise<string | null> {
try {
return await AsyncStorage.getItem(key)
} catch {
// not sure why this would fail... even reading the RN docs I'm unclear
return null
}
}
export async function saveString(key: string, value: string): Promise<boolean> {
try {
await AsyncStorage.setItem(key, value)
return true
} catch {
return false
}
}
export async function load(key: string): Promise<any | null> {
try {
const str = await AsyncStorage.getItem(key)
if (typeof str !== 'string') {
return null
}
return JSON.parse(str)
} catch {
return null
}
}
export async function save(key: string, value: any): Promise<boolean> {
try {
await AsyncStorage.setItem(key, JSON.stringify(value))
return true
} catch {
return false
}
}
export async function remove(key: string): Promise<void> {
try {
await AsyncStorage.removeItem(key)
} catch {}
}
export async function clear(): Promise<void> {
try {
await AsyncStorage.clear()
} catch {}
}

View file

@ -1,14 +0,0 @@
export function isObj(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object'
}
export function hasProp<K extends PropertyKey>(
data: object,
prop: K,
): data is Record<K, unknown> {
return prop in data
}
export function isStrArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(item => typeof item === 'string')
}

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) {