diff options
Diffstat (limited to 'src/lib')
44 files changed, 769 insertions, 784 deletions
diff --git a/src/lib/analytics/analytics.tsx b/src/lib/analytics/analytics.tsx index 71bb8569a..3a8254eb1 100644 --- a/src/lib/analytics/analytics.tsx +++ b/src/lib/analytics/analytics.tsx @@ -1,16 +1,26 @@ import React from 'react' import {AppState, AppStateStatus} from 'react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' import { createClient, AnalyticsProvider, useAnalytics as useAnalyticsOrig, ClientMethods, } from '@segment/analytics-react-native' -import {RootStoreModel, AppInfo} from 'state/models/root-store' -import {useStores} from 'state/models/root-store' +import {z} from 'zod' +import {useSession} from '#/state/session' import {sha256} from 'js-sha256' import {ScreenEvent, TrackEvent} from './types' import {logger} from '#/logger' +import {listenSessionLoaded} from '#/state/events' + +export const appInfo = z.object({ + build: z.string().optional(), + name: z.string().optional(), + namespace: z.string().optional(), + version: z.string().optional(), +}) +export type AppInfo = z.infer<typeof appInfo> const segmentClient = createClient({ writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI', @@ -21,10 +31,10 @@ const segmentClient = createClient({ export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent export function useAnalytics() { - const store = useStores() + const {hasSession} = useSession() const methods: ClientMethods = useAnalyticsOrig() return React.useMemo(() => { - if (store.session.hasSession) { + if (hasSession) { return { screen: methods.screen as ScreenEvent, // ScreenEvents defines all the possible screen names track: methods.track as TrackEvent, // TrackEvents defines all the possible track events and their properties @@ -45,21 +55,18 @@ export function useAnalytics() { alias: () => Promise<void>, reset: () => Promise<void>, } - }, [store, methods]) + }, [hasSession, methods]) } -export function init(store: RootStoreModel) { - store.onSessionLoaded(() => { - const sess = store.session.currentSession - if (sess) { - if (sess.did) { - const did_hashed = sha256(sess.did) - segmentClient.identify(did_hashed, {did_hashed}) - logger.debug('Ping w/hash') - } else { - logger.debug('Ping w/o hash') - segmentClient.identify() - } +export function init() { + listenSessionLoaded(account => { + if (account.did) { + const did_hashed = sha256(account.did) + segmentClient.identify(did_hashed, {did_hashed}) + logger.debug('Ping w/hash') + } else { + logger.debug('Ping w/o hash') + segmentClient.identify() } }) @@ -67,7 +74,7 @@ export function init(store: RootStoreModel) { // this is a copy of segment's own lifecycle event tracking // we handle it manually to ensure that it never fires while the app is backgrounded // -prf - segmentClient.isReady.onChange(() => { + segmentClient.isReady.onChange(async () => { if (AppState.currentState !== 'active') { logger.debug('Prevented a metrics ping while the app was backgrounded') return @@ -78,35 +85,29 @@ export function init(store: RootStoreModel) { return } - const oldAppInfo = store.appInfo + const oldAppInfo = await readAppInfo() const newAppInfo = context.app as AppInfo - store.setAppInfo(newAppInfo) + writeAppInfo(newAppInfo) logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo}) if (typeof oldAppInfo === 'undefined') { - if (store.session.hasSession) { - segmentClient.track('Application Installed', { - version: newAppInfo.version, - build: newAppInfo.build, - }) - } + segmentClient.track('Application Installed', { + version: newAppInfo.version, + build: newAppInfo.build, + }) } else if (newAppInfo.version !== oldAppInfo.version) { - if (store.session.hasSession) { - segmentClient.track('Application Updated', { - version: newAppInfo.version, - build: newAppInfo.build, - previous_version: oldAppInfo.version, - previous_build: oldAppInfo.build, - }) - } - } - if (store.session.hasSession) { - segmentClient.track('Application Opened', { - from_background: false, + segmentClient.track('Application Updated', { version: newAppInfo.version, build: newAppInfo.build, + previous_version: oldAppInfo.version, + previous_build: oldAppInfo.build, }) } + segmentClient.track('Application Opened', { + from_background: false, + version: newAppInfo.version, + build: newAppInfo.build, + }) }) let lastState: AppStateStatus = AppState.currentState @@ -130,3 +131,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) { <AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider> ) } + +async function writeAppInfo(value: AppInfo) { + await AsyncStorage.setItem('BSKY_APP_INFO', JSON.stringify(value)) +} + +async function readAppInfo(): Promise<AppInfo | undefined> { + const rawData = await AsyncStorage.getItem('BSKY_APP_INFO') + const obj = rawData ? JSON.parse(rawData) : undefined + if (!obj || typeof obj !== 'object') { + return undefined + } + return obj +} diff --git a/src/lib/analytics/analytics.web.tsx b/src/lib/analytics/analytics.web.tsx index fe90d1328..0a5d5d689 100644 --- a/src/lib/analytics/analytics.web.tsx +++ b/src/lib/analytics/analytics.web.tsx @@ -4,10 +4,11 @@ import { AnalyticsProvider, useAnalytics as useAnalyticsOrig, } from '@segment/analytics-react' -import {RootStoreModel} from 'state/models/root-store' -import {useStores} from 'state/models/root-store' import {sha256} from 'js-sha256' + +import {useSession} from '#/state/session' import {logger} from '#/logger' +import {listenSessionLoaded} from '#/state/events' const segmentClient = createClient( { @@ -24,10 +25,10 @@ const segmentClient = createClient( export const track = segmentClient?.track?.bind?.(segmentClient) export function useAnalytics() { - const store = useStores() + const {hasSession} = useSession() const methods = useAnalyticsOrig() return React.useMemo(() => { - if (store.session.hasSession) { + if (hasSession) { return methods } // dont send analytics pings for anonymous users @@ -40,15 +41,14 @@ export function useAnalytics() { alias: () => {}, reset: () => {}, } - }, [store, methods]) + }, [hasSession, methods]) } -export function init(store: RootStoreModel) { - store.onSessionLoaded(() => { - const sess = store.session.currentSession - if (sess) { - if (sess.did) { - const did_hashed = sha256(sess.did) +export function init() { + listenSessionLoaded(account => { + if (account.did) { + if (account.did) { + const did_hashed = sha256(account.did) segmentClient.identify(did_hashed, {did_hashed}) logger.debug('Ping w/hash') } else { diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 8f259a910..1123c4e23 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -4,7 +4,7 @@ import { AppBskyEmbedRecordWithMedia, AppBskyEmbedRecord, } from '@atproto/api' -import {FeedSourceInfo} from './feed/types' +import {ReasonFeedSource} from './feed/types' import {isPostInLanguage} from '../../locale/helpers' type FeedViewPost = AppBskyFeedDefs.FeedViewPost @@ -16,13 +16,7 @@ export type FeedTunerFn = ( export class FeedViewPostsSlice { isFlattenedReply = false - constructor(public items: FeedViewPost[] = []) {} - - get _reactKey() { - return `slice-${this.items[0].post.uri}-${ - this.items[0].reason?.indexedAt || this.items[0].post.indexedAt - }` - } + constructor(public items: FeedViewPost[], public _reactKey: string) {} get uri() { if (this.isFlattenedReply) { @@ -65,9 +59,9 @@ export class FeedViewPostsSlice { ) } - get source(): FeedSourceInfo | undefined { + get source(): ReasonFeedSource | undefined { return this.items.find(item => '__source' in item && !!item.__source) - ?.__source as FeedSourceInfo + ?.__source as ReasonFeedSource } containsUri(uri: string) { @@ -116,18 +110,35 @@ export class FeedViewPostsSlice { } } +export class NoopFeedTuner { + private keyCounter = 0 + + reset() { + this.keyCounter = 0 + } + tune( + feed: FeedViewPost[], + _opts?: {dryRun: boolean; maintainOrder: boolean}, + ): FeedViewPostsSlice[] { + return feed.map( + item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`), + ) + } +} + export class FeedTuner { + private keyCounter = 0 seenUris: Set<string> = new Set() - constructor() {} + constructor(public tunerFns: FeedTunerFn[]) {} reset() { + this.keyCounter = 0 this.seenUris.clear() } tune( feed: FeedViewPost[], - tunerFns: FeedTunerFn[] = [], {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = { dryRun: false, maintainOrder: false, @@ -136,7 +147,9 @@ export class FeedTuner { let slices: FeedViewPostsSlice[] = [] if (maintainOrder) { - slices = feed.map(item => new FeedViewPostsSlice([item])) + slices = feed.map( + item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`), + ) } else { // arrange the posts into thread slices for (let i = feed.length - 1; i >= 0; i--) { @@ -152,12 +165,14 @@ export class FeedTuner { continue } } - slices.unshift(new FeedViewPostsSlice([item])) + slices.unshift( + new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`), + ) } } // run the custom tuners - for (const tunerFn of tunerFns) { + for (const tunerFn of this.tunerFns) { slices = tunerFn(this, slices.slice()) } diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts index ec8795e1a..92df84f8b 100644 --- a/src/lib/api/feed/author.ts +++ b/src/lib/api/feed/author.ts @@ -2,37 +2,33 @@ import { AppBskyFeedDefs, AppBskyFeedGetAuthorFeed as GetAuthorFeed, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class AuthorFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor( - public rootStore: RootStoreModel, - public params: GetAuthorFeed.QueryParams, - ) {} - - reset() { - this.cursor = undefined - } + constructor(public params: GetAuthorFeed.QueryParams) {} async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.getAuthorFeed({ + const res = await getAgent().getAuthorFeed({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - const res = await this.rootStore.agent.getAuthorFeed({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + const res = await getAgent().getAuthorFeed({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: this._filter(res.data.feed), diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index d05d5acd6..47ffc65ed 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -2,37 +2,33 @@ import { AppBskyFeedDefs, AppBskyFeedGetFeed as GetCustomFeed, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class CustomFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor( - public rootStore: RootStoreModel, - public params: GetCustomFeed.QueryParams, - ) {} - - reset() { - this.cursor = undefined - } + constructor(public params: GetCustomFeed.QueryParams) {} async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + const res = await getAgent().app.bsky.feed.getFeed({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + const res = await getAgent().app.bsky.feed.getFeed({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor // NOTE // some custom feeds fail to enforce the pagination limit // so we manually truncate here diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts index f14807a57..24389b5ed 100644 --- a/src/lib/api/feed/following.ts +++ b/src/lib/api/feed/following.ts @@ -1,30 +1,29 @@ import {AppBskyFeedDefs} from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class FollowingFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor(public rootStore: RootStoreModel) {} - - reset() { - this.cursor = undefined - } + constructor() {} async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.getTimeline({ + const res = await getAgent().getTimeline({ limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - const res = await this.rootStore.agent.getTimeline({ - cursor: this.cursor, + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + const res = await getAgent().getTimeline({ + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: res.data.feed, diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts index e9bb14b0b..2b0afdf11 100644 --- a/src/lib/api/feed/likes.ts +++ b/src/lib/api/feed/likes.ts @@ -2,37 +2,33 @@ import { AppBskyFeedDefs, AppBskyFeedGetActorLikes as GetActorLikes, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class LikesFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor( - public rootStore: RootStoreModel, - public params: GetActorLikes.QueryParams, - ) {} - - reset() { - this.cursor = undefined - } + constructor(public params: GetActorLikes.QueryParams) {} async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.getActorLikes({ + const res = await getAgent().getActorLikes({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - const res = await this.rootStore.agent.getActorLikes({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + const res = await getAgent().getActorLikes({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: res.data.feed, diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts index e58494675..19f2ff177 100644 --- a/src/lib/api/feed/list.ts +++ b/src/lib/api/feed/list.ts @@ -2,37 +2,33 @@ import { AppBskyFeedDefs, AppBskyFeedGetListFeed as GetListFeed, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class ListFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor( - public rootStore: RootStoreModel, - public params: GetListFeed.QueryParams, - ) {} - - reset() { - this.cursor = undefined - } + constructor(public params: GetListFeed.QueryParams) {} async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ + const res = await getAgent().app.bsky.feed.getListFeed({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + const res = await getAgent().app.bsky.feed.getListFeed({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: res.data.feed, diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index e0fbcecd8..11e963f0a 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -1,11 +1,13 @@ import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api' import shuffle from 'lodash.shuffle' -import {RootStoreModel} from 'state/index' import {timeout} from 'lib/async/timeout' import {bundleAsync} from 'lib/async/bundle' import {feedUriToHref} from 'lib/strings/url-helpers' import {FeedTuner} from '../feed-manip' -import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types' +import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types' +import {FeedParams} from '#/state/queries/post-feed' +import {FeedTunerFn} from '../feed-manip' +import {getAgent} from '#/state/session' const REQUEST_WAIT_MS = 500 // 500ms const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours @@ -17,28 +19,44 @@ export class MergeFeedAPI implements FeedAPI { itemCursor = 0 sampleCursor = 0 - constructor(public rootStore: RootStoreModel) { - this.following = new MergeFeedSource_Following(this.rootStore) + constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) { + this.following = new MergeFeedSource_Following(this.feedTuners) } reset() { - this.following = new MergeFeedSource_Following(this.rootStore) + this.following = new MergeFeedSource_Following(this.feedTuners) this.customFeeds = [] // just empty the array, they will be captured in _fetchNext() this.feedCursor = 0 this.itemCursor = 0 this.sampleCursor = 0 + if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) { + this.customFeeds = shuffle( + this.params.mergeFeedSources.map( + feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners), + ), + ) + } else { + this.customFeeds = [] + } } async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.getTimeline({ + const res = await getAgent().getTimeline({ limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - // we capture here to ensure the data has loaded - this._captureFeedsIfNeeded() + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + if (!cursor) { + this.reset() + } const promises = [] @@ -76,7 +94,7 @@ export class MergeFeedAPI implements FeedAPI { } return { - cursor: posts.length ? 'fake' : undefined, + cursor: posts.length ? String(this.itemCursor) : undefined, feed: posts, } } @@ -107,28 +125,15 @@ export class MergeFeedAPI implements FeedAPI { // provide follow return this.following.take(1) } - - _captureFeedsIfNeeded() { - if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) { - return - } - if (this.customFeeds.length === 0) { - this.customFeeds = shuffle( - this.rootStore.preferences.savedFeeds.map( - feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri), - ), - ) - } - } } class MergeFeedSource { - sourceInfo: FeedSourceInfo | undefined + sourceInfo: ReasonFeedSource | undefined cursor: string | undefined = undefined queue: AppBskyFeedDefs.FeedViewPost[] = [] hasMore = true - constructor(public rootStore: RootStoreModel) {} + constructor(public feedTuners: FeedTunerFn[]) {} get numReady() { return this.queue.length @@ -175,7 +180,7 @@ class MergeFeedSource { } class MergeFeedSource_Following extends MergeFeedSource { - tuner = new FeedTuner() + tuner = new FeedTuner(this.feedTuners) reset() { super.reset() @@ -190,16 +195,12 @@ class MergeFeedSource_Following extends MergeFeedSource { cursor: string | undefined, limit: number, ): Promise<AppBskyFeedGetTimeline.Response> { - const res = await this.rootStore.agent.getTimeline({cursor, limit}) + const res = await getAgent().getTimeline({cursor, limit}) // run the tuner pre-emptively to ensure better mixing - const slices = this.tuner.tune( - res.data.feed, - this.rootStore.preferences.getFeedTuners('home'), - { - dryRun: false, - maintainOrder: true, - }, - ) + const slices = this.tuner.tune(res.data.feed, { + dryRun: false, + maintainOrder: true, + }) res.data.feed = slices.map(slice => slice.rootItem) return res } @@ -208,15 +209,16 @@ class MergeFeedSource_Following extends MergeFeedSource { class MergeFeedSource_Custom extends MergeFeedSource { minDate: Date - constructor(public rootStore: RootStoreModel, public feedUri: string) { - super(rootStore) + constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) { + super(feedTuners) this.sourceInfo = { + $type: 'reasonFeedSource', displayName: feedUri.split('/').pop() || '', uri: feedUriToHref(feedUri), } this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) - this.rootStore.agent.app.bsky.feed - .getFeedGenerator({ + getAgent() + .app.bsky.feed.getFeedGenerator({ feed: feedUri, }) .then( @@ -234,7 +236,7 @@ class MergeFeedSource_Custom extends MergeFeedSource { limit: number, ): Promise<AppBskyFeedGetTimeline.Response> { try { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + const res = await getAgent().app.bsky.feed.getFeed({ cursor, limit, feed: this.feedUri, diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts index 006344334..5d2a90c1d 100644 --- a/src/lib/api/feed/types.ts +++ b/src/lib/api/feed/types.ts @@ -6,12 +6,27 @@ export interface FeedAPIResponse { } export interface FeedAPI { - reset(): void peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> - fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> + fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> } -export interface FeedSourceInfo { +export interface ReasonFeedSource { + $type: 'reasonFeedSource' uri: string displayName: string } + +export function isReasonFeedSource(v: unknown): v is ReasonFeedSource { + return ( + !!v && + typeof v === 'object' && + '$type' in v && + v.$type === 'reasonFeedSource' + ) +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 9d48a78c0..a78abcacd 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -4,12 +4,12 @@ import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyRichtextFacet, + BskyAgent, ComAtprotoLabelDefs, ComAtprotoRepoUploadBlob, RichText, } from '@atproto/api' import {AtUri} from '@atproto/api' -import {RootStoreModel} from 'state/models/root-store' import {isNetworkError} from 'lib/strings/errors' import {LinkMeta} from '../link-meta/link-meta' import {isWeb} from 'platform/detection' @@ -25,46 +25,19 @@ export interface ExternalEmbedDraft { localThumb?: ImageModel } -export async function resolveName(store: RootStoreModel, didOrHandle: string) { - if (!didOrHandle) { - throw new Error('Invalid handle: ""') - } - if (didOrHandle.startsWith('did:')) { - return didOrHandle - } - - // we run the resolution always to ensure freshness - const promise = store.agent - .resolveHandle({ - handle: didOrHandle, - }) - .then(res => { - store.handleResolutions.cache.set(didOrHandle, res.data.did) - return res.data.did - }) - - // but we can return immediately if it's cached - const cached = store.handleResolutions.cache.get(didOrHandle) - if (cached) { - return cached - } - - return promise -} - export async function uploadBlob( - store: RootStoreModel, + agent: BskyAgent, blob: string, encoding: string, ): Promise<ComAtprotoRepoUploadBlob.Response> { if (isWeb) { // `blob` should be a data uri - return store.agent.uploadBlob(convertDataURIToUint8Array(blob), { + return agent.uploadBlob(convertDataURIToUint8Array(blob), { encoding, }) } else { // `blob` should be a path to a file in the local FS - return store.agent.uploadBlob( + return agent.uploadBlob( blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts {encoding}, ) @@ -81,12 +54,11 @@ interface PostOpts { extLink?: ExternalEmbedDraft images?: ImageModel[] labels?: string[] - knownHandles?: Set<string> onStateChange?: (state: string) => void langs?: string[] } -export async function post(store: RootStoreModel, opts: PostOpts) { +export async function post(agent: BskyAgent, opts: PostOpts) { let embed: | AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main @@ -102,7 +74,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { ) opts.onStateChange?.('Processing...') - await rt.detectFacets(store.agent) + await rt.detectFacets(agent) rt = shortenLinks(rt) // filter out any mention facets that didn't map to a user @@ -135,7 +107,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { await image.compress() const path = image.compressed?.path ?? image.path const {width, height} = image.compressed || image - const res = await uploadBlob(store, path, 'image/jpeg') + const res = await uploadBlob(agent, path, 'image/jpeg') images.push({ image: res.data.blob, alt: image.altText ?? '', @@ -185,7 +157,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { } if (encoding) { const thumbUploadRes = await uploadBlob( - store, + agent, opts.extLink.localThumb.path, encoding, ) @@ -224,7 +196,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { // add replyTo if post is a reply to another post if (opts.replyTo) { const replyToUrip = new AtUri(opts.replyTo) - const parentPost = await store.agent.getPost({ + const parentPost = await agent.getPost({ repo: replyToUrip.host, rkey: replyToUrip.rkey, }) @@ -257,7 +229,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { try { opts.onStateChange?.('Posting...') - return await store.agent.post({ + return await agent.post({ text: rt.text, facets: rt.facets, reply, diff --git a/src/lib/async/revertible.ts b/src/lib/async/revertible.ts deleted file mode 100644 index 43383b61e..000000000 --- a/src/lib/async/revertible.ts +++ /dev/null @@ -1,68 +0,0 @@ -import {runInAction} from 'mobx' -import {deepObserve} from 'mobx-utils' -import set from 'lodash.set' - -const ongoingActions = new Set<any>() - -/** - * This is a TypeScript function that optimistically updates data on the client-side before sending a - * request to the server and rolling back changes if the request fails. - * @param {T} model - The object or record that needs to be updated optimistically. - * @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It - * can be used to perform any necessary actions or updates on the model or UI before the server update - * is initiated. - * @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server - * update operation. This function is called after the previous state of the model has been recorded - * and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate` - * function is called with the result - * @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the - * server update is successful. It takes in the response from the server update as its parameter. If - * this parameter is not provided, nothing will happen after the server update. - * @returns A Promise that resolves to `void`. - */ -export const updateDataOptimistically = async < - T extends Record<string, any>, - U, ->( - model: T, - preUpdate: () => void, - serverUpdate: () => Promise<U>, - postUpdate?: (res: U) => void, -): Promise<void> => { - if (ongoingActions.has(model)) { - return - } - ongoingActions.add(model) - - const prevState: Map<string, any> = new Map<string, any>() - const dispose = deepObserve(model, (change, path) => { - if (change.observableKind === 'object') { - if (change.type === 'update') { - prevState.set( - [path, change.name].filter(Boolean).join('.'), - change.oldValue, - ) - } else if (change.type === 'add') { - prevState.set([path, change.name].filter(Boolean).join('.'), undefined) - } - } - }) - preUpdate() - dispose() - - try { - const res = await serverUpdate() - runInAction(() => { - postUpdate?.(res) - }) - } catch (error) { - runInAction(() => { - prevState.forEach((value, path) => { - set(model, path, value) - }) - }) - throw error - } finally { - ongoingActions.delete(model) - } -} diff --git a/src/lib/batchedUpdates.ts b/src/lib/batchedUpdates.ts new file mode 100644 index 000000000..2530d6ca9 --- /dev/null +++ b/src/lib/batchedUpdates.ts @@ -0,0 +1 @@ +export {unstable_batchedUpdates as batchedUpdates} from 'react-native' diff --git a/src/lib/batchedUpdates.web.ts b/src/lib/batchedUpdates.web.ts new file mode 100644 index 000000000..03147ed67 --- /dev/null +++ b/src/lib/batchedUpdates.web.ts @@ -0,0 +1,2 @@ +// @ts-ignore +export {unstable_batchedUpdates as batchedUpdates} from 'react-dom' diff --git a/src/lib/broadcast/index.ts b/src/lib/broadcast/index.ts new file mode 100644 index 000000000..aa3aef580 --- /dev/null +++ b/src/lib/broadcast/index.ts @@ -0,0 +1,11 @@ +export default class BroadcastChannel { + constructor(public name: string) {} + postMessage(_data: any) {} + close() {} + onmessage: (event: MessageEvent) => void = () => {} + addEventListener(_type: string, _listener: (event: MessageEvent) => void) {} + removeEventListener( + _type: string, + _listener: (event: MessageEvent) => void, + ) {} +} diff --git a/src/lib/broadcast/index.web.ts b/src/lib/broadcast/index.web.ts new file mode 100644 index 000000000..33b3548ad --- /dev/null +++ b/src/lib/broadcast/index.web.ts @@ -0,0 +1 @@ +export default BroadcastChannel diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 472b59d76..aa5983be7 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,4 +1,10 @@ -import {Insets} from 'react-native' +import {Insets, Platform} from 'react-native' + +export const LOCAL_DEV_SERVICE = + Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' +export const STAGING_SERVICE = 'https://staging.bsky.dev' +export const PROD_SERVICE = 'https://bsky.social' +export const DEFAULT_SERVICE = PROD_SERVICE const HELP_DESK_LANG = 'en-us' export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}` @@ -43,7 +49,10 @@ export function IS_PROD(url: string) { // until open federation, "production" is defined as the main server // this definition will not work once federation is enabled! // -prf - return url.startsWith('https://bsky.social') + return ( + url.startsWith('https://bsky.social') || + url.startsWith('https://api.bsky.app') + ) } export const PROD_TEAM_HANDLES = [ @@ -107,8 +116,8 @@ export async function DEFAULT_FEEDS( } else { // production return { - pinned: [PROD_DEFAULT_FEED('whats-hot')], - saved: [PROD_DEFAULT_FEED('whats-hot')], + pinned: [], + saved: [], } } } diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 1ddb181a8..8a1dea5fe 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -1,43 +1,55 @@ -import {useCallback, useState} from 'react' -import {useStores} from 'state/index' -import {useAnalytics} from 'lib/analytics/analytics' -import {StackActions, useNavigation} from '@react-navigation/native' -import {NavigationProp} from 'lib/routes/types' -import {AccountData} from 'state/models/session' -import {reset as resetNavigation} from '../../Navigation' -import * as Toast from 'view/com/util/Toast' -import {useSetDrawerOpen} from '#/state/shell/drawer-open' +import {useCallback} from 'react' +import {useNavigation} from '@react-navigation/native' -export function useAccountSwitcher(): [ - boolean, - (v: boolean) => void, - (acct: AccountData) => Promise<void>, -] { +import {isWeb} from '#/platform/detection' +import {NavigationProp} from '#/lib/routes/types' +import {useAnalytics} from '#/lib/analytics/analytics' +import {useSessionApi, SessionAccount} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {useCloseAllActiveElements} from '#/state/util' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' + +export function useAccountSwitcher() { const {track} = useAnalytics() - const store = useStores() - const setDrawerOpen = useSetDrawerOpen() - const [isSwitching, setIsSwitching] = useState(false) + const {selectAccount, clearCurrentAccount} = useSessionApi() + const closeAllActiveElements = useCloseAllActiveElements() const navigation = useNavigation<NavigationProp>() + const {setShowLoggedOut} = useLoggedOutViewControls() const onPressSwitchAccount = useCallback( - async (acct: AccountData) => { + async (account: SessionAccount) => { track('Settings:SwitchAccountButtonClicked') - setIsSwitching(true) - const success = await store.session.resumeSession(acct) - setDrawerOpen(false) - store.shell.closeAllActiveElements() - if (success) { - resetNavigation() - Toast.show(`Signed in as ${acct.displayName || acct.handle}`) - } else { + + try { + if (account.accessJwt) { + closeAllActiveElements() + navigation.navigate(isWeb ? 'Home' : 'HomeTab') + await selectAccount(account) + setTimeout(() => { + Toast.show(`Signed in as @${account.handle}`) + }, 100) + } else { + closeAllActiveElements() + setShowLoggedOut(true) + Toast.show( + `Please sign in as @${account.handle}`, + 'circle-exclamation', + ) + } + } catch (e) { Toast.show('Sorry! We need you to enter your password.') - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - store.session.clear() + clearCurrentAccount() // back user out to login } }, - [track, setIsSwitching, navigation, store, setDrawerOpen], + [ + track, + clearCurrentAccount, + selectAccount, + closeAllActiveElements, + navigation, + setShowLoggedOut, + ], ) - return [isSwitching, setIsSwitching, onPressSwitchAccount] + return {onPressSwitchAccount} } diff --git a/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts b/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts new file mode 100644 index 000000000..56a1e8b11 --- /dev/null +++ b/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts @@ -0,0 +1,15 @@ +// Be warned. This Hook is very buggy unless used in a very constrained way. +// To use it safely: +// +// - DO NOT pass its return value as a prop to any user-defined component. +// - DO NOT pass its return value to more than a single component. +// +// In other words, the only safe way to use it is next to the leaf Reanimated View. +// +// Relevant bug reports: +// - https://github.com/software-mansion/react-native-reanimated/issues/5345 +// - https://github.com/software-mansion/react-native-reanimated/issues/5360 +// - https://github.com/software-mansion/react-native-reanimated/issues/5364 +// +// It's great when it works though. +export {useAnimatedScrollHandler} from 'react-native-reanimated' diff --git a/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts b/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts new file mode 100644 index 000000000..98e05a8ce --- /dev/null +++ b/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts @@ -0,0 +1,44 @@ +import {useRef, useEffect} from 'react' +import {useAnimatedScrollHandler as useAnimatedScrollHandler_BUGGY} from 'react-native-reanimated' + +export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = ( + config, + deps, +) => { + const ref = useRef(config) + useEffect(() => { + ref.current = config + }) + return useAnimatedScrollHandler_BUGGY( + { + onBeginDrag(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onBeginDrag) { + ref.current.onBeginDrag(e, ctx) + } + }, + onEndDrag(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onEndDrag) { + ref.current.onEndDrag(e, ctx) + } + }, + onMomentumBegin(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) { + ref.current.onMomentumBegin(e, ctx) + } + }, + onMomentumEnd(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) { + ref.current.onMomentumEnd(e, ctx) + } + }, + onScroll(e, ctx) { + if (typeof ref.current === 'function') { + ref.current(e, ctx) + } else if (ref.current.onScroll) { + ref.current.onScroll(e, ctx) + } + }, + }, + deps, + ) +} diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts deleted file mode 100644 index 04201b9a1..000000000 --- a/src/lib/hooks/useCustomFeed.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {useEffect, useState} from 'react' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' - -export function useCustomFeed(uri: string): FeedSourceModel | undefined { - const store = useStores() - const [item, setItem] = useState<FeedSourceModel | undefined>() - useEffect(() => { - async function buildFeedItem() { - const model = new FeedSourceModel(store, uri) - await model.setup() - setItem(model) - } - buildFeedItem() - }, [store, uri]) - - return item -} diff --git a/src/lib/hooks/useDesktopRightNavItems.ts b/src/lib/hooks/useDesktopRightNavItems.ts deleted file mode 100644 index f27efd28f..000000000 --- a/src/lib/hooks/useDesktopRightNavItems.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {useEffect, useState} from 'react' -import {useStores} from 'state/index' -import isEqual from 'lodash.isequal' -import {AtUri} from '@atproto/api' -import {FeedSourceModel} from 'state/models/content/feed-source' - -interface RightNavItem { - uri: string - href: string - hostname: string - collection: string - rkey: string - displayName: string -} - -export function useDesktopRightNavItems(uris: string[]): RightNavItem[] { - const store = useStores() - const [items, setItems] = useState<RightNavItem[]>([]) - const [lastUris, setLastUris] = useState<string[]>([]) - - useEffect(() => { - if (isEqual(uris, lastUris)) { - // no changes - return - } - - async function fetchFeedInfo() { - const models = uris - .slice(0, 25) - .map(uri => new FeedSourceModel(store, uri)) - await Promise.all(models.map(m => m.setup())) - setItems( - models.map(model => { - const {hostname, collection, rkey} = new AtUri(model.uri) - return { - uri: model.uri, - href: model.href, - hostname, - collection, - rkey, - displayName: model.displayName, - } - }), - ) - setLastUris(uris) - } - fetchFeedInfo() - }, [store, uris, lastUris, setLastUris, setItems]) - - return items -} diff --git a/src/lib/hooks/useFollowProfile.ts b/src/lib/hooks/useFollowProfile.ts deleted file mode 100644 index 98dd63f5f..000000000 --- a/src/lib/hooks/useFollowProfile.ts +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react' -import {AppBskyActorDefs} from '@atproto/api' -import {useStores} from 'state/index' -import {FollowState} from 'state/models/cache/my-follows' -import {logger} from '#/logger' - -export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) { - const store = useStores() - const state = store.me.follows.getFollowState(profile.did) - - return { - state, - following: state === FollowState.Following, - toggle: React.useCallback(async () => { - if (state === FollowState.Following) { - try { - await store.agent.deleteFollow( - store.me.follows.getFollowUri(profile.did), - ) - store.me.follows.removeFollow(profile.did) - return { - state: FollowState.NotFollowing, - following: false, - } - } catch (e: any) { - logger.error('Failed to delete follow', {error: e}) - throw e - } - } else if (state === FollowState.NotFollowing) { - try { - const res = await store.agent.follow(profile.did) - store.me.follows.addFollow(profile.did, { - followRecordUri: res.uri, - did: profile.did, - handle: profile.handle, - displayName: profile.displayName, - avatar: profile.avatar, - }) - return { - state: FollowState.Following, - following: true, - } - } catch (e: any) { - logger.error('Failed to create follow', {error: e}) - throw e - } - } - - return { - state: FollowState.Unknown, - following: false, - } - }, [store, profile, state]), - } -} diff --git a/src/lib/hooks/useHomeTabs.ts b/src/lib/hooks/useHomeTabs.ts deleted file mode 100644 index 69183e627..000000000 --- a/src/lib/hooks/useHomeTabs.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {useEffect, useState} from 'react' -import {useStores} from 'state/index' -import isEqual from 'lodash.isequal' -import {FeedSourceModel} from 'state/models/content/feed-source' - -export function useHomeTabs(uris: string[]): string[] { - const store = useStores() - const [tabs, setTabs] = useState<string[]>(['Following']) - const [lastUris, setLastUris] = useState<string[]>([]) - - useEffect(() => { - if (isEqual(uris, lastUris)) { - // no changes - return - } - - async function fetchFeedInfo() { - const models = uris - .slice(0, 25) - .map(uri => new FeedSourceModel(store, uri)) - await Promise.all(models.map(m => m.setup())) - setTabs(['Following'].concat(models.map(f => f.displayName))) - setLastUris(uris) - } - fetchFeedInfo() - }, [store, uris, lastUris, setLastUris, setTabs]) - - return tabs -} diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx index ada934a26..e81fc434f 100644 --- a/src/lib/hooks/useMinimalShellMode.tsx +++ b/src/lib/hooks/useMinimalShellMode.tsx @@ -1,60 +1,43 @@ -import React from 'react' -import {autorun} from 'mobx' -import { - Easing, - interpolate, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated' - +import {interpolate, useAnimatedStyle} from 'react-native-reanimated' import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode' +import {useShellLayout} from '#/state/shell/shell-layout' export function useMinimalShellMode() { - const minimalShellMode = useMinimalShellModeState() - const minimalShellInterp = useSharedValue(0) + const mode = useMinimalShellModeState() + const {footerHeight, headerHeight} = useShellLayout() + const footerMinimalShellTransform = useAnimatedStyle(() => { return { - opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]), + pointerEvents: mode.value === 0 ? 'auto' : 'none', + opacity: Math.pow(1 - mode.value, 2), transform: [ - {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])}, + { + translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]), + }, ], } }) const headerMinimalShellTransform = useAnimatedStyle(() => { return { - opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]), + pointerEvents: mode.value === 0 ? 'auto' : 'none', + opacity: Math.pow(1 - mode.value, 2), transform: [ - {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])}, + { + translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]), + }, ], } }) const fabMinimalShellTransform = useAnimatedStyle(() => { return { transform: [ - {translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])}, + { + translateY: interpolate(mode.value, [0, 1], [-44, 0]), + }, ], } }) - - React.useEffect(() => { - return autorun(() => { - if (minimalShellMode) { - minimalShellInterp.value = withTiming(1, { - duration: 125, - easing: Easing.bezier(0.25, 0.1, 0.25, 1), - }) - } else { - minimalShellInterp.value = withTiming(0, { - duration: 125, - easing: Easing.bezier(0.25, 0.1, 0.25, 1), - }) - } - }) - }, [minimalShellInterp, minimalShellMode]) - return { - minimalShellMode, footerMinimalShellTransform, headerMinimalShellTransform, fabMinimalShellTransform, diff --git a/src/lib/hooks/useNonReactiveCallback.ts b/src/lib/hooks/useNonReactiveCallback.ts new file mode 100644 index 000000000..4b3d6abb9 --- /dev/null +++ b/src/lib/hooks/useNonReactiveCallback.ts @@ -0,0 +1,23 @@ +import {useCallback, useInsertionEffect, useRef} from 'react' + +// This should be used sparingly. It erases reactivity, i.e. when the inputs +// change, the function itself will remain the same. This means that if you +// use this at a higher level of your tree, and then some state you read in it +// changes, there is no mechanism for anything below in the tree to "react" +// to this change (e.g. by knowing to call your function again). +// +// Also, you should avoid calling the returned function during rendering +// since the values captured by it are going to lag behind. +export function useNonReactiveCallback<T extends Function>(fn: T): T { + const ref = useRef(fn) + useInsertionEffect(() => { + ref.current = fn + }, [fn]) + return useCallback( + (...args: any) => { + const latestFn = ref.current + return latestFn(...args) + }, + [ref], + ) as unknown as T +} diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts index 0ce97a4c8..55147329b 100644 --- a/src/lib/hooks/useOTAUpdate.ts +++ b/src/lib/hooks/useOTAUpdate.ts @@ -1,26 +1,26 @@ import * as Updates from 'expo-updates' import {useCallback, useEffect} from 'react' import {AppState} from 'react-native' -import {useStores} from 'state/index' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {t} from '@lingui/macro' export function useOTAUpdate() { - const store = useStores() + const {openModal} = useModalControls() // HELPER FUNCTIONS const showUpdatePopup = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Update Available', - message: - 'A new version of the app is available. Please update to continue using the app.', + title: t`Update Available`, + message: t`A new version of the app is available. Please update to continue using the app.`, onPressConfirm: async () => { Updates.reloadAsync().catch(err => { throw err }) }, }) - }, [store.shell]) + }, [openModal]) const checkForUpdate = useCallback(async () => { logger.debug('useOTAUpdate: Checking for update...') try { diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts index 2eab4b250..2e7a79913 100644 --- a/src/lib/hooks/useOnMainScroll.ts +++ b/src/lib/hooks/useOnMainScroll.ts @@ -1,69 +1,125 @@ -import {useState, useCallback, useRef} from 'react' +import {useState, useCallback, useMemo} from 'react' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' -import {s} from 'lib/styles' -import {useWebMediaQueries} from './useWebMediaQueries' import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' +import {useShellLayout} from '#/state/shell/shell-layout' +import {s} from 'lib/styles' +import {isWeb} from 'platform/detection' +import { + useSharedValue, + interpolate, + runOnJS, + ScrollHandlers, +} from 'react-native-reanimated' -const Y_LIMIT = 10 - -const useDeviceLimits = () => { - const {isDesktop} = useWebMediaQueries() - return { - dyLimitUp: isDesktop ? 30 : 10, - dyLimitDown: isDesktop ? 150 : 10, - } +function clamp(num: number, min: number, max: number) { + 'worklet' + return Math.min(Math.max(num, min), max) } export type OnScrollCb = ( event: NativeSyntheticEvent<NativeScrollEvent>, ) => void +export type OnScrollHandler = ScrollHandlers<any> export type ResetCb = () => void -export function useOnMainScroll(): [OnScrollCb, boolean, ResetCb] { - let lastY = useRef(0) - let [isScrolledDown, setIsScrolledDown] = useState(false) - const {dyLimitUp, dyLimitDown} = useDeviceLimits() - const minimalShellMode = useMinimalShellMode() - const setMinimalShellMode = useSetMinimalShellMode() +export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] { + const {headerHeight} = useShellLayout() + const [isScrolledDown, setIsScrolledDown] = useState(false) + const mode = useMinimalShellMode() + const setMode = useSetMinimalShellMode() + const startDragOffset = useSharedValue<number | null>(null) + const startMode = useSharedValue<number | null>(null) - return [ - useCallback( - (event: NativeSyntheticEvent<NativeScrollEvent>) => { - const y = event.nativeEvent.contentOffset.y - const dy = y - (lastY.current || 0) - lastY.current = y + const onBeginDrag = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + }, + [mode, startDragOffset, startMode], + ) - if (!minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) { - setMinimalShellMode(true) - } else if (minimalShellMode && (dy < dyLimitUp * -1 || y <= Y_LIMIT)) { - setMinimalShellMode(false) - } + const onEndDrag = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + startDragOffset.value = null + startMode.value = null + if (e.contentOffset.y < headerHeight.value / 2) { + // If we're close to the top, show the shell. + setMode(false) + } else { + // Snap to whichever state is the closest. + setMode(Math.round(mode.value) === 1) + } + }, + [startDragOffset, startMode, setMode, mode, headerHeight], + ) + + const onScroll = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + // Keep track of whether we want to show "scroll to top". + if (!isScrolledDown && e.contentOffset.y > s.window.height) { + runOnJS(setIsScrolledDown)(true) + } else if (isScrolledDown && e.contentOffset.y < s.window.height) { + runOnJS(setIsScrolledDown)(false) + } - if ( - !isScrolledDown && - event.nativeEvent.contentOffset.y > s.window.height - ) { - setIsScrolledDown(true) - } else if ( - isScrolledDown && - event.nativeEvent.contentOffset.y < s.window.height - ) { - setIsScrolledDown(false) + if (startDragOffset.value === null || startMode.value === null) { + if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) { + // If we're close enough to the top, always show the shell. + // Even if we're not dragging. + setMode(false) + return } - }, - [ - dyLimitDown, - dyLimitUp, - isScrolledDown, - minimalShellMode, - setMinimalShellMode, - ], - ), + if (isWeb) { + // On the web, there is no concept of "starting" the drag. + // When we get the first scroll event, we consider that the start. + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + } + return + } + + // The "mode" value is always between 0 and 1. + // Figure out how much to move it based on the current dragged distance. + const dy = e.contentOffset.y - startDragOffset.value + const dProgress = interpolate( + dy, + [-headerHeight.value, headerHeight.value], + [-1, 1], + ) + const newValue = clamp(startMode.value + dProgress, 0, 1) + if (newValue !== mode.value) { + // Manually adjust the value. This won't be (and shouldn't be) animated. + mode.value = newValue + } + if (isWeb) { + // On the web, there is no concept of "starting" the drag, + // so we don't have any specific anchor point to calculate the distance. + // Instead, update it continuosly along the way and diff with the last event. + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + } + }, + [headerHeight, mode, setMode, isScrolledDown, startDragOffset, startMode], + ) + + const scrollHandler: ScrollHandlers<any> = useMemo( + () => ({ + onBeginDrag, + onEndDrag, + onScroll, + }), + [onBeginDrag, onEndDrag, onScroll], + ) + + return [ + scrollHandler, isScrolledDown, useCallback(() => { setIsScrolledDown(false) - setMinimalShellMode(false) - lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf - }, [setIsScrolledDown, setMinimalShellMode]), + setMode(false) + }, [setMode]), ] } diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts index c5c7a5ca1..129023f71 100644 --- a/src/lib/hooks/useSetTitle.ts +++ b/src/lib/hooks/useSetTitle.ts @@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {bskyTitle} from 'lib/strings/headings' -import {useStores} from 'state/index' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' -/** - * Requires consuming component to be wrapped in `observer`: - * https://stackoverflow.com/a/71488009 - */ export function useSetTitle(title?: string) { const navigation = useNavigation<NavigationProp>() - const {unreadCountLabel} = useStores().me.notifications + const numUnread = useUnreadNotifications() useEffect(() => { if (title) { - navigation.setOptions({title: bskyTitle(title, unreadCountLabel)}) + navigation.setOptions({title: bskyTitle(title, numUnread)}) } - }, [title, navigation, unreadCountLabel]) + }, [title, navigation, numUnread]) } diff --git a/src/lib/hooks/useToggleMutationQueue.ts b/src/lib/hooks/useToggleMutationQueue.ts new file mode 100644 index 000000000..28ae86142 --- /dev/null +++ b/src/lib/hooks/useToggleMutationQueue.ts @@ -0,0 +1,98 @@ +import {useState, useRef, useEffect, useCallback} from 'react' + +type Task<TServerState> = { + isOn: boolean + resolve: (serverState: TServerState) => void + reject: (e: unknown) => void +} + +type TaskQueue<TServerState> = { + activeTask: Task<TServerState> | null + queuedTask: Task<TServerState> | null +} + +function AbortError() { + const e = new Error() + e.name = 'AbortError' + return e +} + +export function useToggleMutationQueue<TServerState>({ + initialState, + runMutation, + onSuccess, +}: { + initialState: TServerState + runMutation: ( + prevState: TServerState, + nextIsOn: boolean, + ) => Promise<TServerState> + onSuccess: (finalState: TServerState) => void +}) { + // We use the queue as a mutable object. + // This is safe becuase it is not used for rendering. + const [queue] = useState<TaskQueue<TServerState>>({ + activeTask: null, + queuedTask: null, + }) + + async function processQueue() { + if (queue.activeTask) { + // There is another active processQueue call iterating over tasks. + // It will handle any newly added tasks, so we should exit early. + return + } + // To avoid relying on the rendered state, capture it once at the start. + // From that point on, and until the queue is drained, we'll use the real server state. + let confirmedState: TServerState = initialState + try { + while (queue.queuedTask) { + const prevTask = queue.activeTask + const nextTask = queue.queuedTask + queue.activeTask = nextTask + queue.queuedTask = null + if (prevTask?.isOn === nextTask.isOn) { + // Skip multiple requests to update to the same value in a row. + prevTask.reject(new (AbortError as any)()) + continue + } + try { + // The state received from the server feeds into the next task. + // This lets us queue deletions of not-yet-created resources. + confirmedState = await runMutation(confirmedState, nextTask.isOn) + nextTask.resolve(confirmedState) + } catch (e) { + nextTask.reject(e) + } + } + } finally { + onSuccess(confirmedState) + queue.activeTask = null + queue.queuedTask = null + } + } + + function queueToggle(isOn: boolean): Promise<TServerState> { + return new Promise((resolve, reject) => { + // This is a toggle, so the next queued value can safely replace the queued one. + if (queue.queuedTask) { + queue.queuedTask.reject(new (AbortError as any)()) + } + queue.queuedTask = {isOn, resolve, reject} + processQueue() + }) + } + + const queueToggleRef = useRef(queueToggle) + useEffect(() => { + queueToggleRef.current = queueToggle + }) + const queueToggleStable = useCallback( + (isOn: boolean): Promise<TServerState> => { + const queueToggleLatest = queueToggleRef.current + return queueToggleLatest(isOn) + }, + [], + ) + return queueToggleStable +} diff --git a/src/lib/hooks/useWebMediaQueries.tsx b/src/lib/hooks/useWebMediaQueries.tsx index 3f43a0aaf..71a96a89b 100644 --- a/src/lib/hooks/useWebMediaQueries.tsx +++ b/src/lib/hooks/useWebMediaQueries.tsx @@ -3,8 +3,8 @@ import {isNative} from 'platform/detection' export function useWebMediaQueries() { const isDesktop = useMediaQuery({minWidth: 1300}) - const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300}) - const isMobile = useMediaQuery({maxWidth: 800}) + const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1}) + const isMobile = useMediaQuery({maxWidth: 800 - 1}) const isTabletOrMobile = isMobile || isTablet const isTabletOrDesktop = isDesktop || isTablet if (isNative) { diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts deleted file mode 100644 index 5c2e68137..000000000 --- a/src/lib/labeling/const.ts +++ /dev/null @@ -1,89 +0,0 @@ -import {LabelPreferencesModel} from 'state/models/ui/preferences' -import {LabelValGroup} from './types' - -export const ILLEGAL_LABEL_GROUP: LabelValGroup = { - id: 'illegal', - title: 'Illegal Content', - warning: 'Illegal Content', - values: ['csam', 'dmca-violation', 'nudity-nonconsensual'], -} - -export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = { - id: 'always-filter', - title: 'Content Warning', - warning: 'Content Warning', - values: ['!filter'], -} - -export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = { - id: 'always-warn', - title: 'Content Warning', - warning: 'Content Warning', - values: ['!warn', 'account-security'], -} - -export const UNKNOWN_LABEL_GROUP: LabelValGroup = { - id: 'unknown', - title: 'Unknown Label', - warning: 'Content Warning', - values: [], -} - -export const CONFIGURABLE_LABEL_GROUPS: Record< - keyof LabelPreferencesModel, - LabelValGroup -> = { - nsfw: { - id: 'nsfw', - title: 'Explicit Sexual Images', - subtitle: 'i.e. pornography', - warning: 'Sexually Explicit', - values: ['porn', 'nsfl'], - isAdultImagery: true, - }, - nudity: { - id: 'nudity', - title: 'Other Nudity', - subtitle: 'Including non-sexual and artistic', - warning: 'Nudity', - values: ['nudity'], - isAdultImagery: true, - }, - suggestive: { - id: 'suggestive', - title: 'Sexually Suggestive', - subtitle: 'Does not include nudity', - warning: 'Sexually Suggestive', - values: ['sexual'], - isAdultImagery: true, - }, - gore: { - id: 'gore', - title: 'Violent / Bloody', - subtitle: 'Gore, self-harm, torture', - warning: 'Violence', - values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'], - isAdultImagery: true, - }, - hate: { - id: 'hate', - title: 'Hate Group Iconography', - subtitle: 'Images of terror groups, articles covering events, etc.', - warning: 'Hate Groups', - values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'], - }, - spam: { - id: 'spam', - title: 'Spam', - subtitle: 'Excessive unwanted interactions', - warning: 'Spam', - values: ['spam'], - }, - impersonation: { - id: 'impersonation', - title: 'Impersonation', - subtitle: 'Accounts falsely claiming to be people or orgs', - warning: 'Impersonation', - values: ['impersonation'], - }, -} diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts deleted file mode 100644 index 84d59be7f..000000000 --- a/src/lib/labeling/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {ComAtprotoLabelDefs} from '@atproto/api' -import {LabelPreferencesModel} from 'state/models/ui/preferences' - -export type Label = ComAtprotoLabelDefs.Label - -export interface LabelValGroup { - id: - | keyof LabelPreferencesModel - | 'illegal' - | 'always-filter' - | 'always-warn' - | 'unknown' - title: string - isAdultImagery?: boolean - subtitle?: string - warning: string - values: string[] -} diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts index b052ed04b..322b02332 100644 --- a/src/lib/link-meta/bsky.ts +++ b/src/lib/link-meta/bsky.ts @@ -1,10 +1,10 @@ +import {AppBskyFeedPost, BskyAgent} from '@atproto/api' import * as apilib from 'lib/api/index' import {LikelyType, LinkMeta} from './link-meta' // import {match as matchRoute} from 'view/routes' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' -import {RootStoreModel} from 'state/index' -import {PostThreadModel} from 'state/models/content/post-thread' -import {ComposerOptsQuote} from 'state/models/ui/shell' +import {ComposerOptsQuote} from 'state/shell/composer' +import {useGetPost} from '#/state/queries/post' // TODO // import {Home} from 'view/screens/Home' @@ -22,7 +22,7 @@ import {ComposerOptsQuote} from 'state/models/ui/shell' // remove once that's implemented // -prf export async function extractBskyMeta( - store: RootStoreModel, + agent: BskyAgent, url: string, ): Promise<LinkMeta> { url = convertBskyAppUrlIfNeeded(url) @@ -102,38 +102,30 @@ export async function extractBskyMeta( } export async function getPostAsQuote( - store: RootStoreModel, + getPost: ReturnType<typeof useGetPost>, url: string, ): Promise<ComposerOptsQuote> { url = convertBskyAppUrlIfNeeded(url) const [_0, user, _1, rkey] = url.split('/').filter(Boolean) - const threadUri = makeRecordUri(user, 'app.bsky.feed.post', rkey) - - const threadView = new PostThreadModel(store, { - uri: threadUri, - depth: 0, - }) - await threadView.setup() - if (!threadView.thread || threadView.notFound) { - throw new Error('Not found') - } + const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) + const post = await getPost({uri: uri}) return { - uri: threadView.thread.post.uri, - cid: threadView.thread.post.cid, - text: threadView.thread.postRecord?.text || '', - indexedAt: threadView.thread.post.indexedAt, - author: threadView.thread.post.author, + uri: post.uri, + cid: post.cid, + text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '', + indexedAt: post.indexedAt, + author: post.author, } } export async function getFeedAsEmbed( - store: RootStoreModel, + agent: BskyAgent, url: string, ): Promise<apilib.ExternalEmbedDraft> { url = convertBskyAppUrlIfNeeded(url) const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey) - const res = await store.agent.app.bsky.feed.getFeedGenerator({feed}) + const res = await agent.app.bsky.feed.getFeedGenerator({feed}) return { isLoading: false, uri: feed, @@ -153,13 +145,13 @@ export async function getFeedAsEmbed( } export async function getListAsEmbed( - store: RootStoreModel, + agent: BskyAgent, url: string, ): Promise<apilib.ExternalEmbedDraft> { url = convertBskyAppUrlIfNeeded(url) const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const list = makeRecordUri(user, 'app.bsky.graph.list', rkey) - const res = await store.agent.app.bsky.graph.getList({list}) + const res = await agent.app.bsky.graph.getList({list}) return { isLoading: false, uri: list, diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts index c490fa292..c17dee51f 100644 --- a/src/lib/link-meta/link-meta.ts +++ b/src/lib/link-meta/link-meta.ts @@ -1,5 +1,5 @@ +import {BskyAgent} from '@atproto/api' import {isBskyAppUrl} from '../strings/url-helpers' -import {RootStoreModel} from 'state/index' import {extractBskyMeta} from './bsky' import {LINK_META_PROXY} from 'lib/constants' @@ -23,12 +23,12 @@ export interface LinkMeta { } export async function getLinkMeta( - store: RootStoreModel, + agent: BskyAgent, url: string, timeout = 5e3, ): Promise<LinkMeta> { if (isBskyAppUrl(url)) { - return extractBskyMeta(store, url) + return extractBskyMeta(agent, url) } let urlp @@ -55,9 +55,9 @@ export async function getLinkMeta( const to = setTimeout(() => controller.abort(), timeout || 5e3) const response = await fetch( - `${LINK_META_PROXY( - store.session.currentSession?.service || '', - )}${encodeURIComponent(url)}`, + `${LINK_META_PROXY(agent.service.toString() || '')}${encodeURIComponent( + url, + )}`, {signal: controller.signal}, ) diff --git a/src/lib/media/alt-text.ts b/src/lib/media/alt-text.ts deleted file mode 100644 index 4109f667a..000000000 --- a/src/lib/media/alt-text.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {RootStoreModel} from 'state/index' -import {ImageModel} from 'state/models/media/image' - -export async function openAltTextModal( - store: RootStoreModel, - image: ImageModel, -) { - store.shell.openModal({ - name: 'alt-text-image', - image, - }) -} diff --git a/src/lib/media/image-sizes.ts b/src/lib/media/image-sizes.ts new file mode 100644 index 000000000..4ea95ea23 --- /dev/null +++ b/src/lib/media/image-sizes.ts @@ -0,0 +1,34 @@ +import {Image} from 'react-native' +import type {Dimensions} from 'lib/media/types' + +const sizes: Map<string, Dimensions> = new Map() +const activeRequests: Map<string, Promise<Dimensions>> = new Map() + +export function get(uri: string): Dimensions | undefined { + return sizes.get(uri) +} + +export async function fetch(uri: string): Promise<Dimensions> { + const Dimensions = sizes.get(uri) + if (Dimensions) { + return Dimensions + } + + const prom = + activeRequests.get(uri) || + new Promise<Dimensions>(resolve => { + Image.getSize( + uri, + (width: number, height: number) => resolve({width, height}), + (err: any) => { + console.error('Failed to fetch image dimensions for', uri, err) + resolve({width: 0, height: 0}) + }, + ) + }) + activeRequests.set(uri, prom) + const res = await prom + activeRequests.delete(uri) + sizes.set(uri, res) + return res +} diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index 9805c3464..096667479 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -1,4 +1,3 @@ -import {RootStoreModel} from 'state/index' import {Image as RNImage} from 'react-native-image-crop-picker' import RNFS from 'react-native-fs' import {CropperOptions} from './types' @@ -22,18 +21,15 @@ async function getFile() { }) } -export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> { +export async function openPicker(): Promise<RNImage[]> { return [await getFile()] } -export async function openCamera(_store: RootStoreModel): Promise<RNImage> { +export async function openCamera(): Promise<RNImage> { return await getFile() } -export async function openCropper( - _store: RootStoreModel, - opts: CropperOptions, -): Promise<RNImage> { +export async function openCropper(opts: CropperOptions): Promise<RNImage> { return { path: opts.path, mime: 'image/jpeg', diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index d0ee1ae22..bf531c981 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -3,23 +3,10 @@ import { openCropper as openCropperFn, Image as RNImage, } from 'react-native-image-crop-picker' -import {RootStoreModel} from 'state/index' import {CameraOpts, CropperOptions} from './types' export {openPicker} from './picker.shared' -/** - * NOTE - * These methods all include the RootStoreModel as the first param - * because the web versions require it. The signatures have to remain - * equivalent between the different forms, but the store param is not - * used here. - * -prf - */ - -export async function openCamera( - _store: RootStoreModel, - opts: CameraOpts, -): Promise<RNImage> { +export async function openCamera(opts: CameraOpts): Promise<RNImage> { const item = await openCameraFn({ width: opts.width, height: opts.height, @@ -39,10 +26,7 @@ export async function openCamera( } } -export async function openCropper( - _store: RootStoreModel, - opts: CropperOptions, -) { +export async function openCropper(opts: CropperOptions) { const item = await openCropperFn({ ...opts, forceJpg: true, // ios only diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index d12685b0c..995a0c95f 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -1,25 +1,19 @@ /// <reference lib="dom" /> import {CameraOpts, CropperOptions} from './types' -import {RootStoreModel} from 'state/index' import {Image as RNImage} from 'react-native-image-crop-picker' export {openPicker} from './picker.shared' +import {unstable__openModal} from '#/state/modals' -export async function openCamera( - _store: RootStoreModel, - _opts: CameraOpts, -): Promise<RNImage> { +export async function openCamera(_opts: CameraOpts): Promise<RNImage> { // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } -export async function openCropper( - store: RootStoreModel, - opts: CropperOptions, -): Promise<RNImage> { +export async function openCropper(opts: CropperOptions): Promise<RNImage> { // TODO handle more opts return new Promise((resolve, reject) => { - store.shell.openModal({ + unstable__openModal({ name: 'crop-image', uri: opts.path, onSelect: (img?: RNImage) => { diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 73f9c56f6..6e79e6b91 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -1,19 +1,20 @@ import * as Notifications from 'expo-notifications' -import {RootStoreModel} from '../../state' +import {QueryClient} from '@tanstack/react-query' import {resetToTab} from '../../Navigation' import {devicePlatform, isIOS} from 'platform/detection' import {track} from 'lib/analytics/analytics' import {logger} from '#/logger' +import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' +import {truncateAndInvalidate} from '#/state/queries/util' +import {listenSessionLoaded} from '#/state/events' const SERVICE_DID = (serviceUrl?: string) => serviceUrl?.includes('staging') ? 'did:web:api.staging.bsky.dev' : 'did:web:api.bsky.app' -export function init(store: RootStoreModel) { - store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count)) - - store.onSessionLoaded(async () => { +export function init(queryClient: QueryClient) { + listenSessionLoaded(async (account, agent) => { // request notifications permission once the user has logged in const perms = await Notifications.getPermissionsAsync() if (!perms.granted) { @@ -24,8 +25,8 @@ export function init(store: RootStoreModel) { const token = await getPushToken() if (token) { try { - await store.agent.api.app.bsky.notification.registerPush({ - serviceDid: SERVICE_DID(store.session.data?.service), + await agent.api.app.bsky.notification.registerPush({ + serviceDid: SERVICE_DID(account.service), platform: devicePlatform, token: token.data, appId: 'xyz.blueskyweb.app', @@ -53,8 +54,8 @@ export function init(store: RootStoreModel) { ) if (t) { try { - await store.agent.api.app.bsky.notification.registerPush({ - serviceDid: SERVICE_DID(store.session.data?.service), + await agent.api.app.bsky.notification.registerPush({ + serviceDid: SERVICE_DID(account.service), platform: devicePlatform, token: t, appId: 'xyz.blueskyweb.app', @@ -83,7 +84,7 @@ export function init(store: RootStoreModel) { ) if (event.request.trigger.type === 'push') { // refresh notifications in the background - store.me.notifications.syncQueue() + truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) // handle payload-based deeplinks let payload if (isIOS) { @@ -121,7 +122,7 @@ export function init(store: RootStoreModel) { logger.DebugContext.notifications, ) track('Notificatons:OpenApp') - store.me.notifications.refresh() // refresh notifications + truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) resetToTab('NotificationsTab') // open notifications tab } }, diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts index 2a8f1d759..6ec620f74 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.ts @@ -1,3 +1,22 @@ import {QueryClient} from '@tanstack/react-query' -export const queryClient = new QueryClient() +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // NOTE + // refetchOnWindowFocus breaks some UIs (like feeds) + // so we NEVER want to enable this + // -prf + refetchOnWindowFocus: false, + // Structural sharing between responses makes it impossible to rely on + // "first seen" timestamps on objects to determine if they're fresh. + // Disable this optimization so that we can rely on "first seen" timestamps. + structuralSharing: false, + // We don't want to retry queries by default, because in most cases we + // want to fail early and show a response to the user. There are + // exceptions, and those can be made on a per-query basis. For others, we + // should give users controls to retry. + retry: false, + }, + }, +}) diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index b080bcc5c..63a21a43c 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -1,8 +1,46 @@ +/** + * Importing these separately from `platform/detection` and `lib/app-info` to + * avoid future conflicts and/or circular deps + */ + +import {Platform} from 'react-native' +import app from 'react-native-version-number' +import * as info from 'expo-updates' import {init} from 'sentry-expo' +/** + * Matches the build profile `channel` props in `eas.json` + */ +const buildChannel = (info.channel || 'development') as + | 'development' + | 'preview' + | 'production' + +/** + * Examples: + * - `dev` + * - `1.57.0` + */ +const release = app.appVersion ?? 'dev' + +/** + * Examples: + * - `web.dev` + * - `ios.dev` + * - `android.dev` + * - `web.1.57.0` + * - `ios.1.57.0.3` + * - `android.1.57.0.46` + */ +const dist = `${Platform.OS}.${release}${ + app.buildVersion ? `.${app.buildVersion}` : '' +}` + init({ dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432', - enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode. debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production - environment: __DEV__ ? 'development' : 'production', // Set the environment + enableInExpoDevelopment: true, + environment: buildChannel, + dist, + release, }) diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 106d2ca31..e9bf4111d 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -1,5 +1,5 @@ import {AtUri} from '@atproto/api' -import {PROD_SERVICE} from 'state/index' +import {PROD_SERVICE} from 'lib/constants' import TLDs from 'tlds' import psl from 'psl' @@ -168,8 +168,15 @@ export function getYoutubeVideoId(link: string): string | undefined { return videoId } +/** + * Checks if the label in the post text matches the host of the link facet. + * + * Hosts are case-insensitive, so should be lowercase for comparison. + * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2 + */ export function linkRequiresWarning(uri: string, label: string) { const labelDomain = labelToDomain(label) + let urip try { urip = new URL(uri) @@ -177,7 +184,9 @@ export function linkRequiresWarning(uri: string, label: string) { return true } - if (urip.hostname === 'bsky.app') { + const host = urip.hostname.toLowerCase() + + if (host === 'bsky.app') { // if this is a link to internal content, // warn if it represents itself as a URL to another app if ( @@ -194,20 +203,26 @@ export function linkRequiresWarning(uri: string, label: string) { if (!labelDomain) { return true } - return labelDomain !== urip.hostname + return labelDomain !== host } } -function labelToDomain(label: string): string | undefined { +/** + * Returns a lowercase domain hostname if the label is a valid URL. + * + * Hosts are case-insensitive, so should be lowercase for comparison. + * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2 + */ +export function labelToDomain(label: string): string | undefined { // any spaces just immediately consider the label a non-url if (/\s/.test(label)) { return undefined } try { - return new URL(label).hostname + return new URL(label).hostname.toLowerCase() } catch {} try { - return new URL('https://' + label).hostname + return new URL('https://' + label).hostname.toLowerCase() } catch {} return undefined } |