Merge branch 'main' into hailey/search-improvements

This commit is contained in:
Dan Abramov
2024-04-26 16:25:34 +01:00
118 changed files with 10075 additions and 9585 deletions
+3 -5
View File
@@ -1,8 +1,9 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals' import {beforeAll, describe, it} from '@jest/globals'
import {expect} from 'detox' import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
import {createServer, loginAsAlice, openApp, sleep} from '../util'
describe('Composer', () => { describe('Composer', () => {
beforeAll(async () => { beforeAll(async () => {
@@ -41,7 +42,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText( await element(by.id('composerTextInput')).typeText(
'Post with a https://example.com link card', 'Post with a https://example.com link card',
) )
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap() await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible() await expect(element(by.id('composeFAB'))).toBeVisible()
}) })
@@ -72,7 +72,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText( await element(by.id('composerTextInput')).typeText(
'Reply with a https://example.com link card', 'Reply with a https://example.com link card',
) )
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap() await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible() await expect(element(by.id('composeFAB'))).toBeVisible()
}) })
@@ -104,7 +103,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText( await element(by.id('composerTextInput')).typeText(
'QP with a https://example.com link card', 'QP with a https://example.com link card',
) )
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap() await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible() await expect(element(by.id('composeFAB'))).toBeVisible()
}) })
+27 -2
View File
@@ -1,9 +1,10 @@
import ImageResizer from '@bam.tech/react-native-image-resizer'
import RNFetchBlob from 'rn-fetch-blob'
import { import {
downloadAndResize, downloadAndResize,
DownloadAndResizeOpts, DownloadAndResizeOpts,
} from '../../src/lib/media/manip' } from '../../src/lib/media/manip'
import ImageResizer from '@bam.tech/react-native-image-resizer'
import RNFetchBlob from 'rn-fetch-blob'
describe('downloadAndResize', () => { describe('downloadAndResize', () => {
const errorSpy = jest.spyOn(global.console, 'error') const errorSpy = jest.spyOn(global.console, 'error')
@@ -30,6 +31,7 @@ describe('downloadAndResize', () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({ mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image.jpg'), path: jest.fn().mockReturnValue('file://downloaded-image.jpg'),
info: jest.fn().mockReturnValue({status: 200}),
flush: jest.fn(), flush: jest.fn(),
}) })
@@ -84,6 +86,7 @@ describe('downloadAndResize', () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({ mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image'), path: jest.fn().mockReturnValue('file://downloaded-image'),
info: jest.fn().mockReturnValue({status: 200}),
flush: jest.fn(), flush: jest.fn(),
}) })
@@ -118,4 +121,26 @@ describe('downloadAndResize', () => {
{mode: 'cover'}, {mode: 'cover'},
) )
}) })
it('should return undefined for non-200 response', async () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image'),
info: jest.fn().mockReturnValue({status: 400}),
flush: jest.fn(),
})
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image',
width: 100,
height: 100,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
const result = await downloadAndResize(opts)
expect(errorSpy).not.toHaveBeenCalled()
expect(result).toBeUndefined()
})
}) })
+10 -4
View File
@@ -122,14 +122,20 @@ func (srv *Server) WebOEmbed(c echo.Context) error {
} }
// TODO: do we actually do something with width? // TODO: do we actually do something with width?
width := 550 width := 600
maxWidthParam := c.QueryParam("maxwidth") maxWidthParam := c.QueryParam("maxwidth")
if maxWidthParam != "" { if maxWidthParam != "" {
maxWidthInt, err := strconv.Atoi(maxWidthParam) maxWidthInt, err := strconv.Atoi(maxWidthParam)
if err != nil || maxWidthInt < 220 || maxWidthInt > 550 { if err != nil {
return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer between 220 and 550)") return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer)")
}
if maxWidthInt < 220 {
width = 220
} else if maxWidthInt > 600 {
width = 600
} else {
width = maxWidthInt
} }
width = maxWidthInt
} }
// NOTE: maxheight ignored // NOTE: maxheight ignored
+2 -2
View File
@@ -16,8 +16,8 @@ import {useQueryClient} from '@tanstack/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import * as persisted from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
import {useIntentHandler} from 'lib/hooks/useIntentHandler' import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {useNotificationsListener} from 'lib/notifications/notifications' import {useNotificationsListener} from 'lib/notifications/notifications'
import {QueryProvider} from 'lib/react-query' import {QueryProvider} from 'lib/react-query'
@@ -64,7 +64,7 @@ function InnerApp() {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`)) Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
}) })
const account = persisted.get('session').currentAccount const account = readLastActiveAccount()
resumeSession(account) resumeSession(account)
}, [resumeSession, _]) }, [resumeSession, _])
+2 -2
View File
@@ -7,8 +7,8 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import * as persisted from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
import {useIntentHandler} from 'lib/hooks/useIntentHandler' import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query' import {QueryProvider} from 'lib/react-query'
import {ThemeProvider} from 'lib/ThemeContext' import {ThemeProvider} from 'lib/ThemeContext'
@@ -42,7 +42,7 @@ function InnerApp() {
// init // init
useEffect(() => { useEffect(() => {
const account = persisted.get('session').currentAccount const account = readLastActiveAccount()
resumeSession(account) resumeSession(account)
}, [resumeSession]) }, [resumeSession])
+3 -1
View File
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation' import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions' import {ReportOption} from '#/lib/moderation/useReportOptions'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, native, useTheme} from '#/alf' import {atoms as a, native, useTheme} from '#/alf'
@@ -35,6 +35,7 @@ export function SubmitView({
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {getAgent} = useAgent()
const [details, setDetails] = React.useState<string>('') const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false) const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>([ const [selectedServices, setSelectedServices] = React.useState<string[]>([
@@ -90,6 +91,7 @@ export function SubmitView({
selectedServices, selectedServices,
onSubmitComplete, onSubmitComplete,
setError, setError,
getAgent,
]) ])
return ( return (
+3 -2
View File
@@ -1,12 +1,13 @@
import React from 'react' import React from 'react'
import {RichText as RichTextAPI} from '@atproto/api' import {RichText as RichTextAPI} from '@atproto/api'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
export function useRichText(text: string): [RichTextAPI, boolean] { export function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = React.useState(text) const [prevText, setPrevText] = React.useState(text)
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text})) const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null) const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
const {getAgent} = useAgent()
if (text !== prevText) { if (text !== prevText) {
setPrevText(text) setPrevText(text)
setRawRT(new RichTextAPI({text})) setRawRT(new RichTextAPI({text}))
@@ -27,7 +28,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
return () => { return () => {
ignore = true ignore = true
} }
}, [text]) }, [text, getAgent])
const isResolving = resolvedRT === null const isResolving = resolvedRT === null
return [resolvedRT ?? rawRT, isResolving] return [resolvedRT ?? rawRT, isResolving]
} }
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
@@ -173,6 +173,7 @@ function AppealForm({
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const [details, setDetails] = React.useState('') const [details, setDetails] = React.useState('')
const isAccountReport = 'did' in subject const isAccountReport = 'did' in subject
const {getAgent} = useAgent()
const onSubmit = async () => { const onSubmit = async () => {
try { try {
+17 -4
View File
@@ -1,15 +1,28 @@
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed, AppBskyFeedGetAuthorFeed as GetAuthorFeed,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class AuthorFeedAPI implements FeedAPI { export class AuthorFeedAPI implements FeedAPI {
constructor(public params: GetAuthorFeed.QueryParams) {} getAgent: () => BskyAgent
params: GetAuthorFeed.QueryParams
constructor({
getAgent,
feedParams,
}: {
getAgent: () => BskyAgent
feedParams: GetAuthorFeed.QueryParams
}) {
this.getAgent = getAgent
this.params = feedParams
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().getAuthorFeed({ const res = await this.getAgent().getAuthorFeed({
...this.params, ...this.params,
limit: 1, limit: 1,
}) })
@@ -23,7 +36,7 @@ export class AuthorFeedAPI implements FeedAPI {
cursor: string | undefined cursor: string | undefined
limit: number limit: number
}): Promise<FeedAPIResponse> { }): Promise<FeedAPIResponse> {
const res = await getAgent().getAuthorFeed({ const res = await this.getAgent().getAuthorFeed({
...this.params, ...this.params,
cursor, cursor,
limit, limit,
+22 -6
View File
@@ -2,18 +2,30 @@ import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed, AppBskyFeedGetFeed as GetCustomFeed,
AtpAgent, AtpAgent,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {getContentLanguages} from '#/state/preferences/languages' import {getContentLanguages} from '#/state/preferences/languages'
import {getAgent} from '#/state/session'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
export class CustomFeedAPI implements FeedAPI { export class CustomFeedAPI implements FeedAPI {
constructor(public params: GetCustomFeed.QueryParams) {} getAgent: () => BskyAgent
params: GetCustomFeed.QueryParams
constructor({
getAgent,
feedParams,
}: {
getAgent: () => BskyAgent
feedParams: GetCustomFeed.QueryParams
}) {
this.getAgent = getAgent
this.params = feedParams
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const contentLangs = getContentLanguages().join(',') const contentLangs = getContentLanguages().join(',')
const res = await getAgent().app.bsky.feed.getFeed( const res = await this.getAgent().app.bsky.feed.getFeed(
{ {
...this.params, ...this.params,
limit: 1, limit: 1,
@@ -31,15 +43,19 @@ export class CustomFeedAPI implements FeedAPI {
limit: number limit: number
}): Promise<FeedAPIResponse> { }): Promise<FeedAPIResponse> {
const contentLangs = getContentLanguages().join(',') const contentLangs = getContentLanguages().join(',')
const agent = getAgent() const agent = this.getAgent()
const res = agent.session const res = agent.session
? await getAgent().app.bsky.feed.getFeed( ? await this.getAgent().app.bsky.feed.getFeed(
{ {
...this.params, ...this.params,
cursor, cursor,
limit, limit,
}, },
{headers: {'Accept-Language': contentLangs}}, {
headers: {
'Accept-Language': contentLangs,
},
},
) )
: await loggedOutFetch({...this.params, cursor, limit}) : await loggedOutFetch({...this.params, cursor, limit})
if (res.success) { if (res.success) {
+9 -5
View File
@@ -1,12 +1,16 @@
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class FollowingFeedAPI implements FeedAPI { export class FollowingFeedAPI implements FeedAPI {
constructor() {} getAgent: () => BskyAgent
constructor({getAgent}: {getAgent: () => BskyAgent}) {
this.getAgent = getAgent
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().getTimeline({ const res = await this.getAgent().getTimeline({
limit: 1, limit: 1,
}) })
return res.data.feed[0] return res.data.feed[0]
@@ -19,7 +23,7 @@ export class FollowingFeedAPI implements FeedAPI {
cursor: string | undefined cursor: string | undefined
limit: number limit: number
}): Promise<FeedAPIResponse> { }): Promise<FeedAPIResponse> {
const res = await getAgent().getTimeline({ const res = await this.getAgent().getTimeline({
cursor, cursor,
limit, limit,
}) })
+18 -9
View File
@@ -1,8 +1,9 @@
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {FollowingFeedAPI} from './following'
import {CustomFeedAPI} from './custom'
import {PROD_DEFAULT_FEED} from '#/lib/constants' import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {CustomFeedAPI} from './custom'
import {FollowingFeedAPI} from './following'
import {FeedAPI, FeedAPIResponse} from './types'
// HACK // HACK
// the feed API does not include any facilities for passing down // the feed API does not include any facilities for passing down
@@ -26,19 +27,27 @@ export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = {
} }
export class HomeFeedAPI implements FeedAPI { export class HomeFeedAPI implements FeedAPI {
getAgent: () => BskyAgent
following: FollowingFeedAPI following: FollowingFeedAPI
discover: CustomFeedAPI discover: CustomFeedAPI
usingDiscover = false usingDiscover = false
itemCursor = 0 itemCursor = 0
constructor() { constructor({getAgent}: {getAgent: () => BskyAgent}) {
this.following = new FollowingFeedAPI() this.getAgent = getAgent
this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')}) this.following = new FollowingFeedAPI({getAgent})
this.discover = new CustomFeedAPI({
getAgent,
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
})
} }
reset() { reset() {
this.following = new FollowingFeedAPI() this.following = new FollowingFeedAPI({getAgent: this.getAgent})
this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')}) this.discover = new CustomFeedAPI({
getAgent: this.getAgent,
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
})
this.usingDiscover = false this.usingDiscover = false
this.itemCursor = 0 this.itemCursor = 0
} }
+17 -4
View File
@@ -1,15 +1,28 @@
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetActorLikes as GetActorLikes, AppBskyFeedGetActorLikes as GetActorLikes,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class LikesFeedAPI implements FeedAPI { export class LikesFeedAPI implements FeedAPI {
constructor(public params: GetActorLikes.QueryParams) {} getAgent: () => BskyAgent
params: GetActorLikes.QueryParams
constructor({
getAgent,
feedParams,
}: {
getAgent: () => BskyAgent
feedParams: GetActorLikes.QueryParams
}) {
this.getAgent = getAgent
this.params = feedParams
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().getActorLikes({ const res = await this.getAgent().getActorLikes({
...this.params, ...this.params,
limit: 1, limit: 1,
}) })
@@ -23,7 +36,7 @@ export class LikesFeedAPI implements FeedAPI {
cursor: string | undefined cursor: string | undefined
limit: number limit: number
}): Promise<FeedAPIResponse> { }): Promise<FeedAPIResponse> {
const res = await getAgent().getActorLikes({ const res = await this.getAgent().getActorLikes({
...this.params, ...this.params,
cursor, cursor,
limit, limit,
+17 -4
View File
@@ -1,15 +1,28 @@
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetListFeed as GetListFeed, AppBskyFeedGetListFeed as GetListFeed,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types' import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
export class ListFeedAPI implements FeedAPI { export class ListFeedAPI implements FeedAPI {
constructor(public params: GetListFeed.QueryParams) {} getAgent: () => BskyAgent
params: GetListFeed.QueryParams
constructor({
getAgent,
feedParams,
}: {
getAgent: () => BskyAgent
feedParams: GetListFeed.QueryParams
}) {
this.getAgent = getAgent
this.params = feedParams
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().app.bsky.feed.getListFeed({ const res = await this.getAgent().app.bsky.feed.getListFeed({
...this.params, ...this.params,
limit: 1, limit: 1,
}) })
@@ -23,7 +36,7 @@ export class ListFeedAPI implements FeedAPI {
cursor: string | undefined cursor: string | undefined
limit: number limit: number
}): Promise<FeedAPIResponse> { }): Promise<FeedAPIResponse> {
const res = await getAgent().app.bsky.feed.getListFeed({ const res = await this.getAgent().app.bsky.feed.getListFeed({
...this.params, ...this.params,
cursor, cursor,
limit, limit,
+72 -17
View File
@@ -1,31 +1,51 @@
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api' import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
import shuffle from 'lodash.shuffle' import shuffle from 'lodash.shuffle'
import {timeout} from 'lib/async/timeout'
import {getContentLanguages} from '#/state/preferences/languages'
import {FeedParams} from '#/state/queries/post-feed'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {timeout} from 'lib/async/timeout'
import {feedUriToHref} from 'lib/strings/url-helpers' import {feedUriToHref} from 'lib/strings/url-helpers'
import {FeedTuner} from '../feed-manip' import {FeedTuner} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip' import {FeedTunerFn} from '../feed-manip'
import {getAgent} from '#/state/session' import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {getContentLanguages} from '#/state/preferences/languages'
const REQUEST_WAIT_MS = 500 // 500ms const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
export class MergeFeedAPI implements FeedAPI { export class MergeFeedAPI implements FeedAPI {
getAgent: () => BskyAgent
params: FeedParams
feedTuners: FeedTunerFn[]
following: MergeFeedSource_Following following: MergeFeedSource_Following
customFeeds: MergeFeedSource_Custom[] = [] customFeeds: MergeFeedSource_Custom[] = []
feedCursor = 0 feedCursor = 0
itemCursor = 0 itemCursor = 0
sampleCursor = 0 sampleCursor = 0
constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) { constructor({
this.following = new MergeFeedSource_Following(this.feedTuners) getAgent,
feedParams,
feedTuners,
}: {
getAgent: () => BskyAgent
feedParams: FeedParams
feedTuners: FeedTunerFn[]
}) {
this.getAgent = getAgent
this.params = feedParams
this.feedTuners = feedTuners
this.following = new MergeFeedSource_Following({
getAgent: this.getAgent,
feedTuners: this.feedTuners,
})
} }
reset() { reset() {
this.following = new MergeFeedSource_Following(this.feedTuners) this.following = new MergeFeedSource_Following({
getAgent: this.getAgent,
feedTuners: this.feedTuners,
})
this.customFeeds = [] this.customFeeds = []
this.feedCursor = 0 this.feedCursor = 0
this.itemCursor = 0 this.itemCursor = 0
@@ -33,7 +53,12 @@ export class MergeFeedAPI implements FeedAPI {
if (this.params.mergeFeedSources) { if (this.params.mergeFeedSources) {
this.customFeeds = shuffle( this.customFeeds = shuffle(
this.params.mergeFeedSources.map( this.params.mergeFeedSources.map(
feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners), feedUri =>
new MergeFeedSource_Custom({
getAgent: this.getAgent,
feedUri,
feedTuners: this.feedTuners,
}),
), ),
) )
} else { } else {
@@ -42,7 +67,7 @@ export class MergeFeedAPI implements FeedAPI {
} }
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().getTimeline({ const res = await this.getAgent().getTimeline({
limit: 1, limit: 1,
}) })
return res.data.feed[0] return res.data.feed[0]
@@ -136,12 +161,23 @@ export class MergeFeedAPI implements FeedAPI {
} }
class MergeFeedSource { class MergeFeedSource {
getAgent: () => BskyAgent
feedTuners: FeedTunerFn[]
sourceInfo: ReasonFeedSource | undefined sourceInfo: ReasonFeedSource | undefined
cursor: string | undefined = undefined cursor: string | undefined = undefined
queue: AppBskyFeedDefs.FeedViewPost[] = [] queue: AppBskyFeedDefs.FeedViewPost[] = []
hasMore = true hasMore = true
constructor(public feedTuners: FeedTunerFn[]) {} constructor({
getAgent,
feedTuners,
}: {
getAgent: () => BskyAgent
feedTuners: FeedTunerFn[]
}) {
this.getAgent = getAgent
this.feedTuners = feedTuners
}
get numReady() { get numReady() {
return this.queue.length return this.queue.length
@@ -203,7 +239,7 @@ class MergeFeedSource_Following extends MergeFeedSource {
cursor: string | undefined, cursor: string | undefined,
limit: number, limit: number,
): Promise<AppBskyFeedGetTimeline.Response> { ): Promise<AppBskyFeedGetTimeline.Response> {
const res = await getAgent().getTimeline({cursor, limit}) const res = await this.getAgent().getTimeline({cursor, limit})
// run the tuner pre-emptively to ensure better mixing // run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune(res.data.feed, { const slices = this.tuner.tune(res.data.feed, {
dryRun: false, dryRun: false,
@@ -215,10 +251,25 @@ class MergeFeedSource_Following extends MergeFeedSource {
} }
class MergeFeedSource_Custom extends MergeFeedSource { class MergeFeedSource_Custom extends MergeFeedSource {
getAgent: () => BskyAgent
minDate: Date minDate: Date
feedUri: string
constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) { constructor({
super(feedTuners) getAgent,
feedUri,
feedTuners,
}: {
getAgent: () => BskyAgent
feedUri: string
feedTuners: FeedTunerFn[]
}) {
super({
getAgent,
feedTuners,
})
this.getAgent = getAgent
this.feedUri = feedUri
this.sourceInfo = { this.sourceInfo = {
$type: 'reasonFeedSource', $type: 'reasonFeedSource',
uri: feedUri, uri: feedUri,
@@ -233,13 +284,17 @@ class MergeFeedSource_Custom extends MergeFeedSource {
): Promise<AppBskyFeedGetTimeline.Response> { ): Promise<AppBskyFeedGetTimeline.Response> {
try { try {
const contentLangs = getContentLanguages().join(',') const contentLangs = getContentLanguages().join(',')
const res = await getAgent().app.bsky.feed.getFeed( const res = await this.getAgent().app.bsky.feed.getFeed(
{ {
cursor, cursor,
limit, limit,
feed: this.feedUri, feed: this.feedUri,
}, },
{headers: {'Accept-Language': contentLangs}}, {
headers: {
'Accept-Language': contentLangs,
},
},
) )
// NOTE // NOTE
// some custom feeds fail to enforce the pagination limit // some custom feeds fail to enforce the pagination limit
+4 -15
View File
@@ -1,4 +1,3 @@
import {deleteAsync} from 'expo-file-system'
import { import {
AppBskyEmbedExternal, AppBskyEmbedExternal,
AppBskyEmbedImages, AppBskyEmbedImages,
@@ -20,6 +19,7 @@ import {shortenLinks} from 'lib/strings/rich-text-manip'
import {isNative, isWeb} from 'platform/detection' import {isNative, isWeb} from 'platform/detection'
import {ImageModel} from 'state/models/media/image' import {ImageModel} from 'state/models/media/image'
import {LinkMeta} from '../link-meta/link-meta' import {LinkMeta} from '../link-meta/link-meta'
import {safeDeleteAsync} from '../media/manip'
export interface ExternalEmbedDraft { export interface ExternalEmbedDraft {
uri: string uri: string
@@ -119,15 +119,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
const {width, height} = image.compressed || image const {width, height} = image.compressed || image
logger.debug(`Uploading image`) logger.debug(`Uploading image`)
const res = await uploadBlob(agent, path, 'image/jpeg') const res = await uploadBlob(agent, path, 'image/jpeg')
if (isNative) { if (isNative) {
try { safeDeleteAsync(path)
deleteAsync(path)
} catch (e) {
console.error(e)
}
} }
images.push({ images.push({
image: res.data.blob, image: res.data.blob,
alt: image.altText ?? '', alt: image.altText ?? '',
@@ -182,13 +176,8 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
encoding, encoding,
) )
thumb = thumbUploadRes.data.blob thumb = thumbUploadRes.data.blob
if (isNative) {
try { safeDeleteAsync(opts.extLink.localThumb.path)
if (isNative) {
deleteAsync(opts.extLink.localThumb.path)
}
} catch (e) {
console.error(e)
} }
} }
} }
-1
View File
@@ -1,3 +1,2 @@
export const LOGIN_INCLUDE_DEV_SERVERS = true export const LOGIN_INCLUDE_DEV_SERVERS = true
export const PWI_ENABLED = true export const PWI_ENABLED = true
export const NEW_ONBOARDING_ENABLED = true
+1
View File
@@ -4,6 +4,7 @@ export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
export const STAGING_SERVICE = 'https://staging.bsky.dev' export const STAGING_SERVICE = 'https://staging.bsky.dev'
export const BSKY_SERVICE = 'https://bsky.social' export const BSKY_SERVICE = 'https://bsky.social'
export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
export const DEFAULT_SERVICE = BSKY_SERVICE export const DEFAULT_SERVICE = BSKY_SERVICE
const HELP_DESK_LANG = 'en-us' const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}` export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
+45 -16
View File
@@ -1,13 +1,14 @@
import RNFetchBlob from 'rn-fetch-blob'
import ImageResizer from '@bam.tech/react-native-image-resizer'
import {Image as RNImage, Share as RNShare} from 'react-native' import {Image as RNImage, Share as RNShare} from 'react-native'
import {Image} from 'react-native-image-crop-picker' import {Image} from 'react-native-image-crop-picker'
import * as RNFS from 'react-native-fs'
import uuid from 'react-native-uuid' import uuid from 'react-native-uuid'
import * as Sharing from 'expo-sharing' import {cacheDirectory, copyAsync, deleteAsync} from 'expo-file-system'
import * as MediaLibrary from 'expo-media-library' import * as MediaLibrary from 'expo-media-library'
import {Dimensions} from './types' import * as Sharing from 'expo-sharing'
import ImageResizer from '@bam.tech/react-native-image-resizer'
import RNFetchBlob from 'rn-fetch-blob'
import {isAndroid, isIOS} from 'platform/detection' import {isAndroid, isIOS} from 'platform/detection'
import {Dimensions} from './types'
export async function compressIfNeeded( export async function compressIfNeeded(
img: Image, img: Image,
@@ -23,7 +24,10 @@ export async function compressIfNeeded(
mode: 'stretch', mode: 'stretch',
maxSize, maxSize,
}) })
const finalImageMovedPath = await moveToPermanentPath(resizedImage.path) const finalImageMovedPath = await moveToPermanentPath(
resizedImage.path,
'.jpg',
)
const finalImg = { const finalImg = {
...resizedImage, ...resizedImage,
path: finalImageMovedPath, path: finalImageMovedPath,
@@ -63,13 +67,15 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
downloadRes = await downloadResPromise downloadRes = await downloadResPromise
clearTimeout(to1) clearTimeout(to1)
let localUri = downloadRes.path() const status = downloadRes.info().status
if (!localUri.startsWith('file://')) { if (status !== 200) {
localUri = `file://${localUri}` return
} }
const localUri = normalizePath(downloadRes.path(), true)
return await doResize(localUri, opts) return await doResize(localUri, opts)
} finally { } finally {
// TODO Whenever we remove `rn-fetch-blob`, we will need to replace this `flush()` with a `deleteAsync()` -hailey
if (downloadRes) { if (downloadRes) {
downloadRes.flush() downloadRes.flush()
} }
@@ -105,7 +111,8 @@ export async function shareImageModal({uri}: {uri: string}) {
UTI: 'image/png', UTI: 'image/png',
}) })
} }
RNFS.unlink(imagePath)
safeDeleteAsync(imagePath)
} }
export async function saveImageToMediaLibrary({uri}: {uri: string}) { export async function saveImageToMediaLibrary({uri}: {uri: string}) {
@@ -122,6 +129,7 @@ export async function saveImageToMediaLibrary({uri}: {uri: string}) {
// save // save
await MediaLibrary.createAssetAsync(imagePath) await MediaLibrary.createAssetAsync(imagePath)
safeDeleteAsync(imagePath)
} }
export function getImageDim(path: string): Promise<Dimensions> { export function getImageDim(path: string): Promise<Dimensions> {
@@ -168,6 +176,8 @@ async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
width: resizeRes.width, width: resizeRes.width,
height: resizeRes.height, height: resizeRes.height,
} }
} else {
safeDeleteAsync(resizeRes.path)
} }
} }
throw new Error( throw new Error(
@@ -175,7 +185,7 @@ async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
) )
} }
async function moveToPermanentPath(path: string, ext = ''): Promise<string> { async function moveToPermanentPath(path: string, ext = 'jpg'): Promise<string> {
/* /*
Since this package stores images in a temp directory, we need to move the file to a permanent location. Since this package stores images in a temp directory, we need to move the file to a permanent location.
Relevant: IOS bug when trying to open a second time: Relevant: IOS bug when trying to open a second time:
@@ -183,14 +193,33 @@ async function moveToPermanentPath(path: string, ext = ''): Promise<string> {
*/ */
const filename = uuid.v4() const filename = uuid.v4()
const destinationPath = joinPath( // cacheDirectory will not ever be null on native, but it could be on web. This function only ever gets called on
RNFS.TemporaryDirectoryPath, // native so we assert as a string.
`${filename}${ext}`, const destinationPath = joinPath(cacheDirectory as string, filename + ext)
) await copyAsync({
await RNFS.moveFile(path, destinationPath) from: normalizePath(path),
to: normalizePath(destinationPath),
})
safeDeleteAsync(path)
return normalizePath(destinationPath) return normalizePath(destinationPath)
} }
export async function safeDeleteAsync(path: string) {
// Normalize is necessary for Android, otherwise it doesn't delete.
const normalizedPath = normalizePath(path)
try {
await Promise.allSettled([
deleteAsync(normalizedPath, {idempotent: true}),
// HACK: Try this one too. Might exist due to api-polyfill hack.
deleteAsync(normalizedPath.replace(/\.jpe?g$/, '.bin'), {
idempotent: true,
}),
])
} catch (e) {
console.error('Failed to delete file', e)
}
}
function joinPath(a: string, b: string) { function joinPath(a: string, b: string) {
if (a.endsWith('/')) { if (a.endsWith('/')) {
if (b.startsWith('/')) { if (b.startsWith('/')) {
+4 -1
View File
@@ -1,12 +1,13 @@
import {useEffect} from 'react' import {useEffect} from 'react'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'
import {BskyAgent} from '@atproto/api'
import {QueryClient} from '@tanstack/react-query' import {QueryClient} from '@tanstack/react-query'
import {logger} from '#/logger' import {logger} from '#/logger'
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread' import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
import {truncateAndInvalidate} from '#/state/queries/util' import {truncateAndInvalidate} from '#/state/queries/util'
import {getAgent, SessionAccount} from '#/state/session' import {SessionAccount} from '#/state/session'
import {track} from 'lib/analytics/analytics' import {track} from 'lib/analytics/analytics'
import {devicePlatform, isIOS} from 'platform/detection' import {devicePlatform, isIOS} from 'platform/detection'
import {resetToTab} from '../../Navigation' import {resetToTab} from '../../Navigation'
@@ -18,6 +19,7 @@ const SERVICE_DID = (serviceUrl?: string) =>
: 'did:web:api.bsky.app' : 'did:web:api.bsky.app'
export async function requestPermissionsAndRegisterToken( export async function requestPermissionsAndRegisterToken(
getAgent: () => BskyAgent,
account: SessionAccount, account: SessionAccount,
) { ) {
// request notifications permission once the user has logged in // request notifications permission once the user has logged in
@@ -49,6 +51,7 @@ export async function requestPermissionsAndRegisterToken(
} }
export function registerTokenChangeHandler( export function registerTokenChangeHandler(
getAgent: () => BskyAgent,
account: SessionAccount, account: SessionAccount,
): () => void { ): () => void {
// listens for new changes to the push token // listens for new changes to the push token
-1
View File
@@ -8,4 +8,3 @@ export type Gate =
| 'start_session_with_following_v2' | 'start_session_with_following_v2'
| 'test_gate_1' | 'test_gate_1'
| 'test_gate_2' | 'test_gate_2'
| 'use_new_suggestions_endpoint'
+11 -2
View File
@@ -352,8 +352,17 @@ export function parseEmbedPlayerFromUrl(
if (id && filename && dimensions && id.includes('AAAAC')) { if (id && filename && dimensions && id.includes('AAAAC')) {
if (Platform.OS === 'web') { if (Platform.OS === 'web') {
id = id.replace('AAAAC', 'AAAP3') const isSafari = /^((?!chrome|android).)*safari/i.test(
filename = filename.replace('.gif', '.webm') navigator.userAgent,
)
if (isSafari) {
id = id.replace('AAAAC', 'AAAP1')
filename = filename.replace('.gif', '.mp4')
} else {
id = id.replace('AAAAC', 'AAAP3')
filename = filename.replace('.gif', '.webm')
}
} else { } else {
id = id.replace('AAAAC', 'AAAAM') id = id.replace('AAAAC', 'AAAAM')
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6
View File
@@ -1,5 +1,6 @@
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {getLocales} from 'expo-localization' import {getLocales} from 'expo-localization'
import {dedupArray} from 'lib/functions' import {dedupArray} from 'lib/functions'
export const isIOS = Platform.OS === 'ios' export const isIOS = Platform.OS === 'ios'
@@ -18,3 +19,8 @@ export const deviceLocales = dedupArray(
.map?.(locale => locale.languageCode) .map?.(locale => locale.languageCode)
.filter(code => typeof code === 'string'), .filter(code => typeof code === 'string'),
) as string[] ) as string[]
export const prefersReducedMotion =
isWeb &&
// @ts-ignore we know window exists -prf
!global.window.matchMedia('(prefers-reduced-motion: no-preference)')?.matches
+17 -10
View File
@@ -1,20 +1,20 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useOnboardingDispatch} from '#/state/shell' import {useLingui} from '@lingui/react'
import {getAgent, isSessionDeactivated, useSessionApi} from '#/state/session'
import {logger} from '#/logger'
import {pluralize} from '#/lib/strings/helpers'
import {atoms as a, useTheme, useBreakpoints} from '#/alf' import {pluralize} from '#/lib/strings/helpers'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {logger} from '#/logger'
import {Text, P} from '#/components/Typography'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {isSessionDeactivated, useAgent, useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {ScrollView} from '#/view/com/util/Views' import {ScrollView} from '#/view/com/util/Views'
import {Loader} from '#/components/Loader'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
const COL_WIDTH = 400 const COL_WIDTH = 400
@@ -25,6 +25,7 @@ export function Deactivated() {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch() const onboardingDispatch = useOnboardingDispatch()
const {logout} = useSessionApi() const {logout} = useSessionApi()
const {getAgent} = useAgent()
const [isProcessing, setProcessing] = React.useState(false) const [isProcessing, setProcessing] = React.useState(false)
const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>( const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>(
@@ -56,7 +57,13 @@ export function Deactivated() {
} finally { } finally {
setProcessing(false) setProcessing(false)
} }
}, [setProcessing, setEstimatedTime, setPlaceInQueue, onboardingDispatch]) }, [
setProcessing,
setEstimatedTime,
setPlaceInQueue,
onboardingDispatch,
getAgent,
])
React.useEffect(() => { React.useEffect(() => {
checkStatus() checkStatus()
+4 -2
View File
@@ -8,7 +8,7 @@ import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useSetSaveFeedsMutation} from '#/state/queries/preferences' import {useSetSaveFeedsMutation} from '#/state/queries/preferences'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import { import {
DescriptionText, DescriptionText,
@@ -38,6 +38,7 @@ export function StepFinished() {
const onboardDispatch = useOnboardingDispatch() const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = React.useState(false) const [saving, setSaving] = React.useState(false)
const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation() const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation()
const {getAgent} = useAgent()
const finishOnboarding = React.useCallback(async () => { const finishOnboarding = React.useCallback(async () => {
setSaving(true) setSaving(true)
@@ -57,6 +58,7 @@ export function StepFinished() {
try { try {
await Promise.all([ await Promise.all([
bulkWriteFollows( bulkWriteFollows(
getAgent,
suggestedAccountsStepResults.accountDids.concat(BSKY_APP_ACCOUNT_DID), suggestedAccountsStepResults.accountDids.concat(BSKY_APP_ACCOUNT_DID),
), ),
// these must be serial // these must be serial
@@ -80,7 +82,7 @@ export function StepFinished() {
track('OnboardingV2:StepFinished:End') track('OnboardingV2:StepFinished:End')
track('OnboardingV2:Complete') track('OnboardingV2:Complete')
logEvent('onboarding:finished:nextPressed', {}) logEvent('onboarding:finished:nextPressed', {})
}, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track]) }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track, getAgent])
React.useEffect(() => { React.useEffect(() => {
track('OnboardingV2:StepFinished:Start') track('OnboardingV2:StepFinished:Start')
@@ -8,7 +8,7 @@ import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize' import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger' import {logger} from '#/logger'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import { import {
DescriptionText, DescriptionText,
@@ -39,6 +39,7 @@ export function StepInterests() {
state.interestsStepResults.selectedInterests.map(i => i), state.interestsStepResults.selectedInterests.map(i => i),
) )
const onboardDispatch = useOnboardingDispatch() const onboardDispatch = useOnboardingDispatch()
const {getAgent} = useAgent()
const {isLoading, isError, error, data, refetch, isFetching} = useQuery({ const {isLoading, isError, error, data, refetch, isFetching} = useQuery({
queryKey: ['interests'], queryKey: ['interests'],
queryFn: async () => { queryFn: async () => {
+15 -4
View File
@@ -1,7 +1,10 @@
import {AppBskyGraphFollow, AppBskyGraphGetFollows} from '@atproto/api' import {
AppBskyGraphFollow,
AppBskyGraphGetFollows,
BskyAgent,
} from '@atproto/api'
import {until} from '#/lib/async/until' import {until} from '#/lib/async/until'
import {getAgent} from '#/state/session'
import {PRIMARY_FEEDS} from './StepAlgoFeeds' import {PRIMARY_FEEDS} from './StepAlgoFeeds'
function shuffle(array: any) { function shuffle(array: any) {
@@ -63,7 +66,10 @@ export function aggregateInterestItems(
return Array.from(new Set(results)).slice(0, 20) return Array.from(new Set(results)).slice(0, 20)
} }
export async function bulkWriteFollows(dids: string[]) { export async function bulkWriteFollows(
getAgent: () => BskyAgent,
dids: string[],
) {
const session = getAgent().session const session = getAgent().session
if (!session) { if (!session) {
@@ -87,10 +93,15 @@ export async function bulkWriteFollows(dids: string[]) {
repo: session.did, repo: session.did,
writes: followWrites, writes: followWrites,
}) })
await whenFollowsIndexed(session.did, res => !!res.data.follows.length) await whenFollowsIndexed(
getAgent,
session.did,
res => !!res.data.follows.length,
)
} }
async function whenFollowsIndexed( async function whenFollowsIndexed(
getAgent: () => BskyAgent,
actor: string, actor: string,
fn: (res: AppBskyGraphGetFollows.Response) => boolean, fn: (res: AppBskyGraphGetFollows.Response) => boolean,
) { ) {
@@ -21,6 +21,7 @@ import {usePreferencesQuery} from '#/state/queries/preferences'
import {useRequireAuth, useSession} from '#/state/session' import {useRequireAuth, useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {useHaptics} from 'lib/haptics' import {useHaptics} from 'lib/haptics'
import {isIOS} from 'platform/detection'
import {useProfileShadow} from 'state/cache/profile-shadow' import {useProfileShadow} from 'state/cache/profile-shadow'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu' import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@@ -164,10 +165,12 @@ let ProfileHeaderLabeler = ({
moderation={moderation} moderation={moderation}
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
isPlaceholderProfile={isPlaceholderProfile}> isPlaceholderProfile={isPlaceholderProfile}>
<View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none"> <View
style={[a.px_lg, a.pt_md, a.pb_sm]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View <View
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]} style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]}
pointerEvents="box-none"> pointerEvents={isIOS ? 'auto' : 'box-none'}>
{isMe ? ( {isMe ? (
<Button <Button
testID="profileHeaderEditProfileButton" testID="profileHeaderEditProfileButton"
@@ -12,7 +12,7 @@ import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {isIOS, isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import { import {
@@ -152,10 +152,12 @@ let ProfileHeaderStandard = ({
moderation={moderation} moderation={moderation}
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
isPlaceholderProfile={isPlaceholderProfile}> isPlaceholderProfile={isPlaceholderProfile}>
<View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none"> <View
style={[a.px_lg, a.pt_md, a.pb_sm]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View <View
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_sm]} style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_sm]}
pointerEvents="box-none"> pointerEvents={isIOS ? 'auto' : 'box-none'}>
{isMe ? ( {isMe ? (
<Button <Button
testID="profileHeaderEditProfileButton" testID="profileHeaderEditProfileButton"
+6 -3
View File
@@ -12,6 +12,7 @@ import {useSession} from '#/state/session'
import {BACK_HITSLOP} from 'lib/constants' import {BACK_HITSLOP} from 'lib/constants'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {isIOS} from 'platform/detection'
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
import {UserBanner} from 'view/com/util/UserBanner' import {UserBanner} from 'view/com/util/UserBanner'
@@ -61,8 +62,8 @@ let ProfileHeaderShell = ({
) )
return ( return (
<View style={t.atoms.bg} pointerEvents="box-none"> <View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View pointerEvents="none"> <View pointerEvents={isIOS ? 'auto' : 'none'}>
{isPlaceholderProfile ? ( {isPlaceholderProfile ? (
<LoadingPlaceholder <LoadingPlaceholder
width="100%" width="100%"
@@ -80,7 +81,9 @@ let ProfileHeaderShell = ({
{children} {children}
<View style={[a.px_lg, a.pb_sm]} pointerEvents="box-none"> <View
style={[a.px_lg, a.pb_sm]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
<ProfileHeaderAlerts moderation={moderation} /> <ProfileHeaderAlerts moderation={moderation} />
{isMe && ( {isMe && (
<LabelsOnMe details={{did: profile.did}} labels={profile.labels} /> <LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
+3 -1
View File
@@ -9,7 +9,7 @@ import {FEEDBACK_FORM_URL} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {createFullHandle} from '#/lib/strings/handles' import {createFullHandle} from '#/lib/strings/handles'
import {useServiceQuery} from '#/state/queries/service' import {useServiceQuery} from '#/state/queries/service'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
import { import {
initialState, initialState,
@@ -35,6 +35,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
const [state, dispatch] = React.useReducer(reducer, initialState) const [state, dispatch] = React.useReducer(reducer, initialState)
const submit = useSubmitSignup({state, dispatch}) const submit = useSubmitSignup({state, dispatch})
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {getAgent} = useAgent()
const { const {
data: serviceInfo, data: serviceInfo,
@@ -113,6 +114,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
state.serviceDescription?.phoneVerificationRequired, state.serviceDescription?.phoneVerificationRequired,
state.userDomain, state.userDomain,
submit, submit,
getAgent,
]) ])
const onBackPress = React.useCallback(() => { const onBackPress = React.useCallback(() => {
+3 -2
View File
@@ -1,6 +1,6 @@
import {z} from 'zod' import {z} from 'zod'
import {deviceLocales} from '#/platform/detection' import {deviceLocales, prefersReducedMotion} from '#/platform/detection'
const externalEmbedOptions = ['show', 'hide'] as const const externalEmbedOptions = ['show', 'hide'] as const
@@ -15,6 +15,7 @@ const accountSchema = z.object({
refreshJwt: z.string().optional(), // optional because it can expire refreshJwt: z.string().optional(), // optional because it can expire
accessJwt: z.string().optional(), // optional because it can expire accessJwt: z.string().optional(), // optional because it can expire
deactivated: z.boolean().optional(), deactivated: z.boolean().optional(),
pdsUrl: z.string().optional(),
}) })
export type PersistedAccount = z.infer<typeof accountSchema> export type PersistedAccount = z.infer<typeof accountSchema>
@@ -98,5 +99,5 @@ export const defaults: Schema = {
lastSelectedHomeFeed: undefined, lastSelectedHomeFeed: undefined,
pdsAddressHistory: [], pdsAddressHistory: [],
disableHaptics: false, disableHaptics: false,
disableAutoplay: false, disableAutoplay: prefersReducedMotion,
} }
+4 -2
View File
@@ -5,7 +5,7 @@ import {useQuery, useQueryClient} from '@tanstack/react-query'
import {isJustAMute} from '#/lib/moderation' import {isJustAMute} from '#/lib/moderation'
import {logger} from '#/logger' import {logger} from '#/logger'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences' import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
const DEFAULT_MOD_OPTS = { const DEFAULT_MOD_OPTS = {
@@ -18,6 +18,7 @@ export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix]
export function useActorAutocompleteQuery(prefix: string) { export function useActorAutocompleteQuery(prefix: string) {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {getAgent} = useAgent()
prefix = prefix.toLowerCase() prefix = prefix.toLowerCase()
@@ -46,6 +47,7 @@ export type ActorAutocompleteFn = ReturnType<typeof useActorAutocompleteFn>
export function useActorAutocompleteFn() { export function useActorAutocompleteFn() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {getAgent} = useAgent()
return React.useCallback( return React.useCallback(
async ({query, limit = 8}: {query: string; limit?: number}) => { async ({query, limit = 8}: {query: string; limit?: number}) => {
@@ -74,7 +76,7 @@ export function useActorAutocompleteFn() {
moderationOpts || DEFAULT_MOD_OPTS, moderationOpts || DEFAULT_MOD_OPTS,
) )
}, },
[queryClient, moderationOpts], [queryClient, moderationOpts, getAgent],
) )
} }
+2 -1
View File
@@ -2,7 +2,7 @@ import {AppBskyActorDefs} from '@atproto/api'
import {QueryClient, useQuery} from '@tanstack/react-query' import {QueryClient, useQuery} from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'actor-search' const RQKEY_ROOT = 'actor-search'
export const RQKEY = (query: string) => [RQKEY_ROOT, query] export const RQKEY = (query: string) => [RQKEY_ROOT, query]
@@ -14,6 +14,7 @@ export function useActorSearch({
query: string query: string
enabled?: boolean enabled?: boolean
}) { }) {
const {getAgent} = useAgent()
return useQuery<AppBskyActorDefs.ProfileView[]>({ return useQuery<AppBskyActorDefs.ProfileView[]>({
staleTime: STALE.MINUTES.ONE, staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(query || ''), queryKey: RQKEY(query || ''),
+4 -1
View File
@@ -2,12 +2,13 @@ import {ComAtprotoServerCreateAppPassword} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent} from '../session' import {useAgent} from '../session'
const RQKEY_ROOT = 'app-passwords' const RQKEY_ROOT = 'app-passwords'
export const RQKEY = () => [RQKEY_ROOT] export const RQKEY = () => [RQKEY_ROOT]
export function useAppPasswordsQuery() { export function useAppPasswordsQuery() {
const {getAgent} = useAgent()
return useQuery({ return useQuery({
staleTime: STALE.MINUTES.FIVE, staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY(), queryKey: RQKEY(),
@@ -20,6 +21,7 @@ export function useAppPasswordsQuery() {
export function useAppPasswordCreateMutation() { export function useAppPasswordCreateMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation< return useMutation<
ComAtprotoServerCreateAppPassword.OutputSchema, ComAtprotoServerCreateAppPassword.OutputSchema,
Error, Error,
@@ -42,6 +44,7 @@ export function useAppPasswordCreateMutation() {
export function useAppPasswordDeleteMutation() { export function useAppPasswordDeleteMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {name: string}>({ return useMutation<void, Error, {name: string}>({
mutationFn: async ({name}) => { mutationFn: async ({name}) => {
await getAgent().com.atproto.server.revokeAppPassword({ await getAgent().com.atproto.server.revokeAppPassword({
+5 -1
View File
@@ -17,7 +17,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {getAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {router} from '#/routes' import {router} from '#/routes'
export type FeedSourceFeedInfo = { export type FeedSourceFeedInfo = {
@@ -140,6 +140,7 @@ export function getAvatarTypeFromUri(uri: string) {
export function useFeedSourceInfoQuery({uri}: {uri: string}) { export function useFeedSourceInfoQuery({uri}: {uri: string}) {
const type = getFeedTypeFromUri(uri) const type = getFeedTypeFromUri(uri)
const {getAgent} = useAgent()
return useQuery({ return useQuery({
staleTime: STALE.INFINITY, staleTime: STALE.INFINITY,
@@ -166,6 +167,7 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) {
export const useGetPopularFeedsQueryKey = ['getPopularFeeds'] export const useGetPopularFeedsQueryKey = ['getPopularFeeds']
export function useGetPopularFeedsQuery() { export function useGetPopularFeedsQuery() {
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema, AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema,
Error, Error,
@@ -187,6 +189,7 @@ export function useGetPopularFeedsQuery() {
} }
export function useSearchPopularFeedsMutation() { export function useSearchPopularFeedsMutation() {
const {getAgent} = useAgent()
return useMutation({ return useMutation({
mutationFn: async (query: string) => { mutationFn: async (query: string) => {
const res = await getAgent().app.bsky.unspecced.getPopularFeedGenerators({ const res = await getAgent().app.bsky.unspecced.getPopularFeedGenerators({
@@ -238,6 +241,7 @@ const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
export function usePinnedFeedsInfos() { export function usePinnedFeedsInfos() {
const {hasSession} = useSession() const {hasSession} = useSession()
const {getAgent} = useAgent()
const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
const pinnedUris = preferences?.feeds?.pinned ?? [] const pinnedUris = preferences?.feeds?.pinned ?? []
+6 -3
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const handleQueryKeyRoot = 'handle' const handleQueryKeyRoot = 'handle'
const fetchHandleQueryKey = (handleOrDid: string) => [ const fetchHandleQueryKey = (handleOrDid: string) => [
@@ -14,6 +14,7 @@ const fetchDidQueryKey = (handleOrDid: string) => [didQueryKeyRoot, handleOrDid]
export function useFetchHandle() { export function useFetchHandle() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return React.useCallback( return React.useCallback(
async (handleOrDid: string) => { async (handleOrDid: string) => {
@@ -27,12 +28,13 @@ export function useFetchHandle() {
} }
return handleOrDid return handleOrDid
}, },
[queryClient], [queryClient, getAgent],
) )
} }
export function useUpdateHandleMutation() { export function useUpdateHandleMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({ return useMutation({
mutationFn: async ({handle}: {handle: string}) => { mutationFn: async ({handle}: {handle: string}) => {
@@ -48,6 +50,7 @@ export function useUpdateHandleMutation() {
export function useFetchDid() { export function useFetchDid() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return React.useCallback( return React.useCallback(
async (handleOrDid: string) => { async (handleOrDid: string) => {
@@ -64,6 +67,6 @@ export function useFetchDid() {
}, },
}) })
}, },
[queryClient], [queryClient, getAgent],
) )
} }
+3 -1
View File
@@ -1,7 +1,9 @@
import {BskyAgent} from '@atproto/api' import {BskyAgent} from '@atproto/api'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
export const PUBLIC_BSKY_AGENT = new BskyAgent({ export const PUBLIC_BSKY_AGENT = new BskyAgent({
service: 'https://public.api.bsky.app', service: PUBLIC_BSKY_SERVICE,
}) })
export const STALE = { export const STALE = {
+2 -1
View File
@@ -3,7 +3,7 @@ import {useQuery} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean { function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean {
return invite.available - invite.uses.length > 0 && !invite.disabled return invite.available - invite.uses.length > 0 && !invite.disabled
@@ -16,6 +16,7 @@ export type InviteCodesQueryResponse = Exclude<
undefined undefined
> >
export function useInviteCodesQuery() { export function useInviteCodesQuery() {
const {getAgent} = useAgent()
return useQuery({ return useQuery({
staleTime: STALE.MINUTES.FIVE, staleTime: STALE.MINUTES.FIVE,
queryKey: [inviteCodesQueryKeyRoot], queryKey: [inviteCodesQueryKeyRoot],
+5 -1
View File
@@ -5,7 +5,7 @@ import {z} from 'zod'
import {labelersDetailedInfoQueryKeyRoot} from '#/lib/react-query' import {labelersDetailedInfoQueryKeyRoot} from '#/lib/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {preferencesQueryKey} from '#/state/queries/preferences' import {preferencesQueryKey} from '#/state/queries/preferences'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const labelerInfoQueryKeyRoot = 'labeler-info' const labelerInfoQueryKeyRoot = 'labeler-info'
export const labelerInfoQueryKey = (did: string) => [ export const labelerInfoQueryKey = (did: string) => [
@@ -31,6 +31,7 @@ export function useLabelerInfoQuery({
did?: string did?: string
enabled?: boolean enabled?: boolean
}) { }) {
const {getAgent} = useAgent()
return useQuery({ return useQuery({
enabled: !!did && enabled !== false, enabled: !!did && enabled !== false,
queryKey: labelerInfoQueryKey(did as string), queryKey: labelerInfoQueryKey(did as string),
@@ -45,6 +46,7 @@ export function useLabelerInfoQuery({
} }
export function useLabelersInfoQuery({dids}: {dids: string[]}) { export function useLabelersInfoQuery({dids}: {dids: string[]}) {
const {getAgent} = useAgent()
return useQuery({ return useQuery({
enabled: !!dids.length, enabled: !!dids.length,
queryKey: labelersInfoQueryKey(dids), queryKey: labelersInfoQueryKey(dids),
@@ -56,6 +58,7 @@ export function useLabelersInfoQuery({dids}: {dids: string[]}) {
} }
export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) { export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
const {getAgent} = useAgent()
return useQuery({ return useQuery({
enabled: !!dids.length, enabled: !!dids.length,
queryKey: labelersDetailedInfoQueryKey(dids), queryKey: labelersDetailedInfoQueryKey(dids),
@@ -73,6 +76,7 @@ export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
export function useLabelerSubscriptionMutation() { export function useLabelerSubscriptionMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({ return useMutation({
async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) { async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) {
+3 -1
View File
@@ -1,8 +1,9 @@
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
export function useLikeMutation() { export function useLikeMutation() {
const {getAgent} = useAgent()
return useMutation({ return useMutation({
mutationFn: async ({uri, cid}: {uri: string; cid: string}) => { mutationFn: async ({uri, cid}: {uri: string; cid: string}) => {
const res = await getAgent().like(uri, cid) const res = await getAgent().like(uri, cid)
@@ -12,6 +13,7 @@ export function useLikeMutation() {
} }
export function useUnlikeMutation() { export function useUnlikeMutation() {
const {getAgent} = useAgent()
return useMutation({ return useMutation({
mutationFn: async ({uri}: {uri: string}) => { mutationFn: async ({uri}: {uri: string}) => {
await getAgent().deleteLike(uri) await getAgent().deleteLike(uri)
+2 -1
View File
@@ -7,7 +7,7 @@ import {
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
@@ -16,6 +16,7 @@ const RQKEY_ROOT = 'list-members'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
export function useListMembersQuery(uri: string) { export function useListMembersQuery(uri: string) {
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyGraphGetList.OutputSchema, AppBskyGraphGetList.OutputSchema,
Error, Error,
+4 -1
View File
@@ -19,7 +19,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members' import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members'
import {getAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records // sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records
const SANITY_PAGE_LIMIT = 1000 const SANITY_PAGE_LIMIT = 1000
@@ -40,6 +40,7 @@ export interface ListMembersip {
*/ */
export function useDangerousListMembershipsQuery() { export function useDangerousListMembershipsQuery() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
return useQuery<ListMembersip[]>({ return useQuery<ListMembersip[]>({
staleTime: STALE.MINUTES.FIVE, staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY(), queryKey: RQKEY(),
@@ -91,6 +92,7 @@ export function getMembership(
export function useListMembershipAddMutation() { export function useListMembershipAddMutation() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation< return useMutation<
{uri: string; cid: string}, {uri: string; cid: string},
@@ -149,6 +151,7 @@ export function useListMembershipAddMutation() {
export function useListMembershipRemoveMutation() { export function useListMembershipRemoveMutation() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation< return useMutation<
void, void,
+49 -21
View File
@@ -4,6 +4,7 @@ import {
AppBskyGraphGetList, AppBskyGraphGetList,
AppBskyGraphList, AppBskyGraphList,
AtUri, AtUri,
BskyAgent,
Facet, Facet,
} from '@atproto/api' } from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
@@ -12,7 +13,7 @@ import chunk from 'lodash.chunk'
import {uploadBlob} from '#/lib/api' import {uploadBlob} from '#/lib/api'
import {until} from '#/lib/async/until' import {until} from '#/lib/async/until'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent, useSession} from '../session' import {useAgent, useSession} from '../session'
import {invalidate as invalidateMyLists} from './my-lists' import {invalidate as invalidateMyLists} from './my-lists'
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists' import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
@@ -20,6 +21,7 @@ const RQKEY_ROOT = 'list'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
export function useListQuery(uri?: string) { export function useListQuery(uri?: string) {
const {getAgent} = useAgent()
return useQuery<AppBskyGraphDefs.ListView, Error>({ return useQuery<AppBskyGraphDefs.ListView, Error>({
staleTime: STALE.MINUTES.ONE, staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
@@ -47,6 +49,7 @@ export interface ListCreateMutateParams {
export function useListCreateMutation() { export function useListCreateMutation() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>( return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
{ {
async mutationFn({ async mutationFn({
@@ -85,9 +88,13 @@ export function useListCreateMutation() {
) )
// wait for the appview to update // wait for the appview to update
await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => { await whenAppViewReady(
return typeof v?.data?.list.uri === 'string' getAgent,
}) res.uri,
(v: AppBskyGraphGetList.Response) => {
return typeof v?.data?.list.uri === 'string'
},
)
return res return res
}, },
onSuccess() { onSuccess() {
@@ -109,6 +116,7 @@ export interface ListMetadataMutateParams {
} }
export function useListMetadataMutation() { export function useListMetadataMutation() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation< return useMutation<
{uri: string; cid: string}, {uri: string; cid: string},
@@ -150,12 +158,16 @@ export function useListMetadataMutation() {
).data ).data
// wait for the appview to update // wait for the appview to update
await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => { await whenAppViewReady(
const list = v.data.list getAgent,
return ( res.uri,
list.name === record.name && list.description === record.description (v: AppBskyGraphGetList.Response) => {
) const list = v.data.list
}) return (
list.name === record.name && list.description === record.description
)
},
)
return res return res
}, },
onSuccess(data, variables) { onSuccess(data, variables) {
@@ -172,6 +184,7 @@ export function useListMetadataMutation() {
export function useListDeleteMutation() { export function useListDeleteMutation() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation<void, Error, {uri: string}>({ return useMutation<void, Error, {uri: string}>({
mutationFn: async ({uri}) => { mutationFn: async ({uri}) => {
@@ -220,9 +233,13 @@ export function useListDeleteMutation() {
} }
// wait for the appview to update // wait for the appview to update
await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => { await whenAppViewReady(
return !v?.success getAgent,
}) uri,
(v: AppBskyGraphGetList.Response) => {
return !v?.success
},
)
}, },
onSuccess() { onSuccess() {
invalidateMyLists(queryClient) invalidateMyLists(queryClient)
@@ -236,6 +253,7 @@ export function useListDeleteMutation() {
export function useListMuteMutation() { export function useListMuteMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {uri: string; mute: boolean}>({ return useMutation<void, Error, {uri: string; mute: boolean}>({
mutationFn: async ({uri, mute}) => { mutationFn: async ({uri, mute}) => {
if (mute) { if (mute) {
@@ -244,9 +262,13 @@ export function useListMuteMutation() {
await getAgent().unmuteModList(uri) await getAgent().unmuteModList(uri)
} }
await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => { await whenAppViewReady(
return Boolean(v?.data.list.viewer?.muted) === mute getAgent,
}) uri,
(v: AppBskyGraphGetList.Response) => {
return Boolean(v?.data.list.viewer?.muted) === mute
},
)
}, },
onSuccess(data, variables) { onSuccess(data, variables) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -258,6 +280,7 @@ export function useListMuteMutation() {
export function useListBlockMutation() { export function useListBlockMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {uri: string; block: boolean}>({ return useMutation<void, Error, {uri: string; block: boolean}>({
mutationFn: async ({uri, block}) => { mutationFn: async ({uri, block}) => {
if (block) { if (block) {
@@ -266,11 +289,15 @@ export function useListBlockMutation() {
await getAgent().unblockModList(uri) await getAgent().unblockModList(uri)
} }
await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => { await whenAppViewReady(
return block getAgent,
? typeof v?.data.list.viewer?.blocked === 'string' uri,
: !v?.data.list.viewer?.blocked (v: AppBskyGraphGetList.Response) => {
}) return block
? typeof v?.data.list.viewer?.blocked === 'string'
: !v?.data.list.viewer?.blocked
},
)
}, },
onSuccess(data, variables) { onSuccess(data, variables) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -281,6 +308,7 @@ export function useListBlockMutation() {
} }
async function whenAppViewReady( async function whenAppViewReady(
getAgent: () => BskyAgent,
uri: string, uri: string,
fn: (res: AppBskyGraphGetList.Response) => boolean, fn: (res: AppBskyGraphGetList.Response) => boolean,
) { ) {
+2 -1
View File
@@ -6,13 +6,14 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'my-blocked-accounts' const RQKEY_ROOT = 'my-blocked-accounts'
export const RQKEY = () => [RQKEY_ROOT] export const RQKEY = () => [RQKEY_ROOT]
type RQPageParam = string | undefined type RQPageParam = string | undefined
export function useMyBlockedAccountsQuery() { export function useMyBlockedAccountsQuery() {
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyGraphGetBlocks.OutputSchema, AppBskyGraphGetBlocks.OutputSchema,
Error, Error,
+2 -1
View File
@@ -3,7 +3,7 @@ import {QueryClient, useQuery} from '@tanstack/react-query'
import {accumulate} from '#/lib/async/accumulate' import {accumulate} from '#/lib/async/accumulate'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
export type MyListsFilter = export type MyListsFilter =
| 'all' | 'all'
@@ -16,6 +16,7 @@ export const RQKEY = (filter: MyListsFilter) => [RQKEY_ROOT, filter]
export function useMyListsQuery(filter: MyListsFilter) { export function useMyListsQuery(filter: MyListsFilter) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
return useQuery<AppBskyGraphDefs.ListView[]>({ return useQuery<AppBskyGraphDefs.ListView[]>({
staleTime: STALE.MINUTES.ONE, staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(filter), queryKey: RQKEY(filter),
+2 -1
View File
@@ -6,13 +6,14 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'my-muted-accounts' const RQKEY_ROOT = 'my-muted-accounts'
export const RQKEY = () => [RQKEY_ROOT] export const RQKEY = () => [RQKEY_ROOT]
type RQPageParam = string | undefined type RQPageParam = string | undefined
export function useMyMutedAccountsQuery() { export function useMyMutedAccountsQuery() {
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyGraphGetMutes.OutputSchema, AppBskyGraphGetMutes.OutputSchema,
Error, Error,
+3
View File
@@ -27,6 +27,7 @@ import {
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {useMutedThreads} from '#/state/muted-threads' import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session'
import {STALE} from '..' import {STALE} from '..'
import {useModerationOpts} from '../preferences' import {useModerationOpts} from '../preferences'
import {embedViewRecordToPostView, getEmbeddedPost} from '../util' import {embedViewRecordToPostView, getEmbeddedPost} from '../util'
@@ -46,6 +47,7 @@ export function RQKEY() {
} }
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads() const threadMutes = useMutedThreads()
@@ -71,6 +73,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
if (!page) { if (!page) {
page = ( page = (
await fetchPage({ await fetchPage({
getAgent,
limit: PAGE_SIZE, limit: PAGE_SIZE,
cursor: pageParam, cursor: pageParam,
queryClient, queryClient,
+4 -2
View File
@@ -12,7 +12,7 @@ import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useMutedThreads} from '#/state/muted-threads' import {useMutedThreads} from '#/state/muted-threads'
import {getAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '../preferences' import {useModerationOpts} from '../preferences'
import {truncateAndInvalidate} from '../util' import {truncateAndInvalidate} from '../util'
import {RQKEY as RQKEY_NOTIFS} from './feed' import {RQKEY as RQKEY_NOTIFS} from './feed'
@@ -46,6 +46,7 @@ const apiContext = React.createContext<ApiContext>({
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const {hasSession} = useSession() const {hasSession} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads() const threadMutes = useMutedThreads()
@@ -144,6 +145,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// count // count
const {page, indexedAt: lastIndexed} = await fetchPage({ const {page, indexedAt: lastIndexed} = await fetchPage({
getAgent,
cursor: undefined, cursor: undefined,
limit: 40, limit: 40,
queryClient, queryClient,
@@ -196,7 +198,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
}, },
} }
}, [setNumUnread, queryClient, moderationOpts, threadMutes]) }, [setNumUnread, queryClient, moderationOpts, threadMutes, getAgent])
checkUnreadRef.current = api.checkUnread checkUnreadRef.current = api.checkUnread
return ( return (
+13 -9
View File
@@ -1,18 +1,19 @@
import { import {
AppBskyNotificationListNotifications, AppBskyEmbedRecord,
ModerationOpts,
moderateNotification,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedLike,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyFeedRepost, AppBskyFeedRepost,
AppBskyFeedLike, AppBskyNotificationListNotifications,
AppBskyEmbedRecord, BskyAgent,
moderateNotification,
ModerationOpts,
} from '@atproto/api' } from '@atproto/api'
import chunk from 'lodash.chunk'
import {QueryClient} from '@tanstack/react-query' import {QueryClient} from '@tanstack/react-query'
import {getAgent} from '../../session' import chunk from 'lodash.chunk'
import {precacheProfile} from '../profile' import {precacheProfile} from '../profile'
import {NotificationType, FeedNotification, FeedPage} from './types' import {FeedNotification, FeedPage, NotificationType} from './types'
const GROUPABLE_REASONS = ['like', 'repost', 'follow'] const GROUPABLE_REASONS = ['like', 'repost', 'follow']
const MS_1HR = 1e3 * 60 * 60 const MS_1HR = 1e3 * 60 * 60
@@ -22,6 +23,7 @@ const MS_2DAY = MS_1HR * 48
// = // =
export async function fetchPage({ export async function fetchPage({
getAgent,
cursor, cursor,
limit, limit,
queryClient, queryClient,
@@ -29,6 +31,7 @@ export async function fetchPage({
threadMutes, threadMutes,
fetchAdditionalData, fetchAdditionalData,
}: { }: {
getAgent: () => BskyAgent
cursor: string | undefined cursor: string | undefined
limit: number limit: number
queryClient: QueryClient queryClient: QueryClient
@@ -53,7 +56,7 @@ export async function fetchPage({
// we fetch subjects of notifications (usually posts) now instead of lazily // we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts // in the UI to avoid relayouts
if (fetchAdditionalData) { if (fetchAdditionalData) {
const subjects = await fetchSubjects(notifsGrouped) const subjects = await fetchSubjects(getAgent, notifsGrouped)
for (const notif of notifsGrouped) { for (const notif of notifsGrouped) {
if (notif.subjectUri) { if (notif.subjectUri) {
notif.subject = subjects.get(notif.subjectUri) notif.subject = subjects.get(notif.subjectUri)
@@ -137,6 +140,7 @@ export function groupNotifications(
} }
async function fetchSubjects( async function fetchSubjects(
getAgent: () => BskyAgent,
groupedNotifs: FeedNotification[], groupedNotifs: FeedNotification[],
): Promise<Map<string, AppBskyFeedDefs.PostView>> { ): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>() const uris = new Set<string>()
+36 -17
View File
@@ -4,6 +4,7 @@ import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AtUri, AtUri,
BskyAgent,
ModerationDecision, ModerationDecision,
} from '@atproto/api' } from '@atproto/api'
import { import {
@@ -19,7 +20,7 @@ import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {logger} from '#/logger' import {logger} from '#/logger'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {AuthorFeedAPI} from 'lib/api/feed/author' import {AuthorFeedAPI} from 'lib/api/feed/author'
import {CustomFeedAPI} from 'lib/api/feed/custom' import {CustomFeedAPI} from 'lib/api/feed/custom'
import {FollowingFeedAPI} from 'lib/api/feed/following' import {FollowingFeedAPI} from 'lib/api/feed/following'
@@ -104,6 +105,7 @@ export function usePostFeedQuery(
const queryClient = useQueryClient() const queryClient = useQueryClient()
const feedTuners = useFeedTuners(feedDesc) const feedTuners = useFeedTuners(feedDesc)
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {getAgent} = useAgent()
const enabled = opts?.enabled !== false && Boolean(moderationOpts) const enabled = opts?.enabled !== false && Boolean(moderationOpts)
const lastRun = useRef<{ const lastRun = useRef<{
data: InfiniteData<FeedPageUnselected> data: InfiniteData<FeedPageUnselected>
@@ -135,11 +137,15 @@ export function usePostFeedQuery(
queryKey: RQKEY(feedDesc, params), queryKey: RQKEY(feedDesc, params),
async queryFn({pageParam}: {pageParam: RQPageParam}) { async queryFn({pageParam}: {pageParam: RQPageParam}) {
logger.debug('usePostFeedQuery', {feedDesc, cursor: pageParam?.cursor}) logger.debug('usePostFeedQuery', {feedDesc, cursor: pageParam?.cursor})
const {api, cursor} = pageParam const {api, cursor} = pageParam
? pageParam ? pageParam
: { : {
api: createApi(feedDesc, params || {}, feedTuners), api: createApi({
feedDesc,
feedParams: params || {},
feedTuners,
getAgent,
}),
cursor: undefined, cursor: undefined,
} }
@@ -365,34 +371,47 @@ export async function pollLatest(page: FeedPage | undefined) {
return false return false
} }
function createApi( function createApi({
feedDesc: FeedDescriptor, feedDesc,
params: FeedParams, feedParams,
feedTuners: FeedTunerFn[], feedTuners,
) { getAgent,
}: {
feedDesc: FeedDescriptor
feedParams: FeedParams
feedTuners: FeedTunerFn[]
getAgent: () => BskyAgent
}) {
if (feedDesc === 'home') { if (feedDesc === 'home') {
if (params.mergeFeedEnabled) { if (feedParams.mergeFeedEnabled) {
return new MergeFeedAPI(params, feedTuners) return new MergeFeedAPI({
getAgent,
feedParams,
feedTuners,
})
} else { } else {
return new HomeFeedAPI() return new HomeFeedAPI({getAgent})
} }
} else if (feedDesc === 'following') { } else if (feedDesc === 'following') {
return new FollowingFeedAPI() return new FollowingFeedAPI({getAgent})
} else if (feedDesc.startsWith('author')) { } else if (feedDesc.startsWith('author')) {
const [_, actor, filter] = feedDesc.split('|') const [_, actor, filter] = feedDesc.split('|')
return new AuthorFeedAPI({actor, filter}) return new AuthorFeedAPI({getAgent, feedParams: {actor, filter}})
} else if (feedDesc.startsWith('likes')) { } else if (feedDesc.startsWith('likes')) {
const [_, actor] = feedDesc.split('|') const [_, actor] = feedDesc.split('|')
return new LikesFeedAPI({actor}) return new LikesFeedAPI({getAgent, feedParams: {actor}})
} else if (feedDesc.startsWith('feedgen')) { } else if (feedDesc.startsWith('feedgen')) {
const [_, feed] = feedDesc.split('|') const [_, feed] = feedDesc.split('|')
return new CustomFeedAPI({feed}) return new CustomFeedAPI({
getAgent,
feedParams: {feed},
})
} else if (feedDesc.startsWith('list')) { } else if (feedDesc.startsWith('list')) {
const [_, list] = feedDesc.split('|') const [_, list] = feedDesc.split('|')
return new ListFeedAPI({list}) return new ListFeedAPI({getAgent, feedParams: {list}})
} else { } else {
// shouldnt happen // shouldnt happen
return new FollowingFeedAPI() return new FollowingFeedAPI({getAgent})
} }
} }
+2 -1
View File
@@ -6,7 +6,7 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
@@ -16,6 +16,7 @@ const RQKEY_ROOT = 'liked-by'
export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri] export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri]
export function useLikedByQuery(resolvedUri: string | undefined) { export function useLikedByQuery(resolvedUri: string | undefined) {
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyFeedGetLikes.OutputSchema, AppBskyFeedGetLikes.OutputSchema,
Error, Error,
+2 -1
View File
@@ -6,7 +6,7 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
@@ -16,6 +16,7 @@ const RQKEY_ROOT = 'post-reposted-by'
export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri] export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri]
export function usePostRepostedByQuery(resolvedUri: string | undefined) { export function usePostRepostedByQuery(resolvedUri: string | undefined) {
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyFeedGetRepostedBy.OutputSchema, AppBskyFeedGetRepostedBy.OutputSchema,
Error, Error,
+2 -1
View File
@@ -7,7 +7,7 @@ import {
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from 'state/queries/search-posts' import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from 'state/queries/search-posts'
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed' import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed'
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed' import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
@@ -66,6 +66,7 @@ export type ThreadNode =
export function usePostThreadQuery(uri: string | undefined) { export function usePostThreadQuery(uri: string | undefined) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useQuery<ThreadNode, Error>({ return useQuery<ThreadNode, Error>({
gcTime: 0, gcTime: 0,
queryKey: RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
+9 -2
View File
@@ -7,13 +7,14 @@ import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig' import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {updatePostShadow} from '#/state/cache/post-shadow' import {updatePostShadow} from '#/state/cache/post-shadow'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {getAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {findProfileQueryData} from './profile' import {findProfileQueryData} from './profile'
const RQKEY_ROOT = 'post' const RQKEY_ROOT = 'post'
export const RQKEY = (postUri: string) => [RQKEY_ROOT, postUri] export const RQKEY = (postUri: string) => [RQKEY_ROOT, postUri]
export function usePostQuery(uri: string | undefined) { export function usePostQuery(uri: string | undefined) {
const {getAgent} = useAgent()
return useQuery<AppBskyFeedDefs.PostView>({ return useQuery<AppBskyFeedDefs.PostView>({
queryKey: RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
async queryFn() { async queryFn() {
@@ -30,6 +31,7 @@ export function usePostQuery(uri: string | undefined) {
export function useGetPost() { export function useGetPost() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useCallback( return useCallback(
async ({uri}: {uri: string}) => { async ({uri}: {uri: string}) => {
return queryClient.fetchQuery({ return queryClient.fetchQuery({
@@ -56,7 +58,7 @@ export function useGetPost() {
}, },
}) })
}, },
[queryClient], [queryClient, getAgent],
) )
} }
@@ -125,6 +127,7 @@ function usePostLikeMutation(
const {currentAccount} = useSession() const {currentAccount} = useSession()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const postAuthor = post.author const postAuthor = post.author
const {getAgent} = useAgent()
return useMutation< return useMutation<
{uri: string}, // responds with the uri of the like {uri: string}, // responds with the uri of the like
Error, Error,
@@ -162,6 +165,7 @@ function usePostLikeMutation(
function usePostUnlikeMutation( function usePostUnlikeMutation(
logContext: LogEvents['post:unlike']['logContext'], logContext: LogEvents['post:unlike']['logContext'],
) { ) {
const {getAgent} = useAgent()
return useMutation<void, Error, {postUri: string; likeUri: string}>({ return useMutation<void, Error, {postUri: string; likeUri: string}>({
mutationFn: ({likeUri}) => { mutationFn: ({likeUri}) => {
logEvent('post:unlike', {logContext}) logEvent('post:unlike', {logContext})
@@ -234,6 +238,7 @@ export function usePostRepostMutationQueue(
function usePostRepostMutation( function usePostRepostMutation(
logContext: LogEvents['post:repost']['logContext'], logContext: LogEvents['post:repost']['logContext'],
) { ) {
const {getAgent} = useAgent()
return useMutation< return useMutation<
{uri: string}, // responds with the uri of the repost {uri: string}, // responds with the uri of the repost
Error, Error,
@@ -252,6 +257,7 @@ function usePostRepostMutation(
function usePostUnrepostMutation( function usePostUnrepostMutation(
logContext: LogEvents['post:unrepost']['logContext'], logContext: LogEvents['post:unrepost']['logContext'],
) { ) {
const {getAgent} = useAgent()
return useMutation<void, Error, {postUri: string; repostUri: string}>({ return useMutation<void, Error, {postUri: string; repostUri: string}>({
mutationFn: ({repostUri}) => { mutationFn: ({repostUri}) => {
logEvent('post:unrepost', {logContext}) logEvent('post:unrepost', {logContext})
@@ -265,6 +271,7 @@ function usePostUnrepostMutation(
export function usePostDeleteMutation() { export function usePostDeleteMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {uri: string}>({ return useMutation<void, Error, {uri: string}>({
mutationFn: async ({uri}) => { mutationFn: async ({uri}) => {
await getAgent().deletePost(uri) await getAgent().deletePost(uri)
+17 -1
View File
@@ -22,7 +22,7 @@ import {
ThreadViewPreferences, ThreadViewPreferences,
UsePreferencesQueryResponse, UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types' } from '#/state/queries/preferences/types'
import {getAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {saveLabelers} from '#/state/session/agent-config' import {saveLabelers} from '#/state/session/agent-config'
export * from '#/state/queries/preferences/const' export * from '#/state/queries/preferences/const'
@@ -33,6 +33,7 @@ const preferencesQueryKeyRoot = 'getPreferences'
export const preferencesQueryKey = [preferencesQueryKeyRoot] export const preferencesQueryKey = [preferencesQueryKeyRoot]
export function usePreferencesQuery() { export function usePreferencesQuery() {
const {getAgent} = useAgent()
return useQuery({ return useQuery({
staleTime: STALE.SECONDS.FIFTEEN, staleTime: STALE.SECONDS.FIFTEEN,
structuralSharing: true, structuralSharing: true,
@@ -118,6 +119,7 @@ export function useModerationOpts() {
export function useClearPreferencesMutation() { export function useClearPreferencesMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({ return useMutation({
mutationFn: async () => { mutationFn: async () => {
@@ -131,6 +133,7 @@ export function useClearPreferencesMutation() {
} }
export function usePreferencesSetContentLabelMutation() { export function usePreferencesSetContentLabelMutation() {
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation< return useMutation<
@@ -150,6 +153,7 @@ export function usePreferencesSetContentLabelMutation() {
export function useSetContentLabelMutation() { export function useSetContentLabelMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({
@@ -172,6 +176,7 @@ export function useSetContentLabelMutation() {
export function usePreferencesSetAdultContentMutation() { export function usePreferencesSetAdultContentMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {enabled: boolean}>({ return useMutation<void, unknown, {enabled: boolean}>({
mutationFn: async ({enabled}) => { mutationFn: async ({enabled}) => {
@@ -186,6 +191,7 @@ export function usePreferencesSetAdultContentMutation() {
export function usePreferencesSetBirthDateMutation() { export function usePreferencesSetBirthDateMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {birthDate: Date}>({ return useMutation<void, unknown, {birthDate: Date}>({
mutationFn: async ({birthDate}: {birthDate: Date}) => { mutationFn: async ({birthDate}: {birthDate: Date}) => {
@@ -200,6 +206,7 @@ export function usePreferencesSetBirthDateMutation() {
export function useSetFeedViewPreferencesMutation() { export function useSetFeedViewPreferencesMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, Partial<BskyFeedViewPreference>>({ return useMutation<void, unknown, Partial<BskyFeedViewPreference>>({
mutationFn: async prefs => { mutationFn: async prefs => {
@@ -214,6 +221,7 @@ export function useSetFeedViewPreferencesMutation() {
export function useSetThreadViewPreferencesMutation() { export function useSetThreadViewPreferencesMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, Partial<ThreadViewPreferences>>({ return useMutation<void, unknown, Partial<ThreadViewPreferences>>({
mutationFn: async prefs => { mutationFn: async prefs => {
@@ -228,6 +236,7 @@ export function useSetThreadViewPreferencesMutation() {
export function useSetSaveFeedsMutation() { export function useSetSaveFeedsMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation< return useMutation<
void, void,
@@ -246,6 +255,7 @@ export function useSetSaveFeedsMutation() {
export function useSaveFeedMutation() { export function useSaveFeedMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {uri: string}>({ return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => { mutationFn: async ({uri}) => {
@@ -261,6 +271,7 @@ export function useSaveFeedMutation() {
export function useRemoveFeedMutation() { export function useRemoveFeedMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {uri: string}>({ return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => { mutationFn: async ({uri}) => {
@@ -276,6 +287,7 @@ export function useRemoveFeedMutation() {
export function usePinFeedMutation() { export function usePinFeedMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {uri: string}>({ return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => { mutationFn: async ({uri}) => {
@@ -291,6 +303,7 @@ export function usePinFeedMutation() {
export function useUnpinFeedMutation() { export function useUnpinFeedMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {uri: string}>({ return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => { mutationFn: async ({uri}) => {
@@ -306,6 +319,7 @@ export function useUnpinFeedMutation() {
export function useUpsertMutedWordsMutation() { export function useUpsertMutedWordsMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({ return useMutation({
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => { mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
@@ -320,6 +334,7 @@ export function useUpsertMutedWordsMutation() {
export function useUpdateMutedWordMutation() { export function useUpdateMutedWordMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({ return useMutation({
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
@@ -334,6 +349,7 @@ export function useUpdateMutedWordMutation() {
export function useRemoveMutedWordMutation() { export function useRemoveMutedWordMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({ return useMutation({
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
+2 -1
View File
@@ -1,7 +1,7 @@
import {AppBskyFeedGetActorFeeds} from '@atproto/api' import {AppBskyFeedGetActorFeeds} from '@atproto/api'
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query' import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
@@ -15,6 +15,7 @@ export function useProfileFeedgensQuery(
opts?: {enabled?: boolean}, opts?: {enabled?: boolean},
) { ) {
const enabled = opts?.enabled !== false const enabled = opts?.enabled !== false
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyFeedGetActorFeeds.OutputSchema, AppBskyFeedGetActorFeeds.OutputSchema,
Error, Error,
+2 -1
View File
@@ -6,7 +6,7 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
@@ -15,6 +15,7 @@ const RQKEY_ROOT = 'profile-followers'
export const RQKEY = (did: string) => [RQKEY_ROOT, did] export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileFollowersQuery(did: string | undefined) { export function useProfileFollowersQuery(did: string | undefined) {
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyGraphGetFollowers.OutputSchema, AppBskyGraphGetFollowers.OutputSchema,
Error, Error,
+2 -1
View File
@@ -7,7 +7,7 @@ import {
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
@@ -17,6 +17,7 @@ const RQKEY_ROOT = 'profile-follows'
export const RQKEY = (did: string) => [RQKEY_ROOT, did] export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileFollowsQuery(did: string | undefined) { export function useProfileFollowsQuery(did: string | undefined) {
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyGraphGetFollows.OutputSchema, AppBskyGraphGetFollows.OutputSchema,
Error, Error,
+2 -1
View File
@@ -1,7 +1,7 @@
import {AppBskyGraphGetLists} from '@atproto/api' import {AppBskyGraphGetLists} from '@atproto/api'
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query' import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
@@ -11,6 +11,7 @@ export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
const enabled = opts?.enabled !== false const enabled = opts?.enabled !== false
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyGraphGetLists.OutputSchema, AppBskyGraphGetLists.OutputSchema,
Error, Error,
+15 -2
View File
@@ -8,6 +8,7 @@ import {
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs, AppBskyFeedDefs,
AtUri, AtUri,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import { import {
QueryClient, QueryClient,
@@ -25,7 +26,7 @@ import {Shadow} from '#/state/cache/types'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {updateProfileShadow} from '../cache/profile-shadow' import {updateProfileShadow} from '../cache/profile-shadow'
import {getAgent, useSession} from '../session' import {useAgent, useSession} from '../session'
import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
import {ThreadNode} from './post-thread' import {ThreadNode} from './post-thread'
@@ -53,6 +54,7 @@ export function useProfileQuery({
staleTime?: number staleTime?: number
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useQuery<AppBskyActorDefs.ProfileViewDetailed>({ return useQuery<AppBskyActorDefs.ProfileViewDetailed>({
// WARNING // WARNING
// this staleTime is load-bearing // this staleTime is load-bearing
@@ -77,6 +79,7 @@ export function useProfileQuery({
} }
export function useProfilesQuery({handles}: {handles: string[]}) { export function useProfilesQuery({handles}: {handles: string[]}) {
const {getAgent} = useAgent()
return useQuery({ return useQuery({
staleTime: STALE.MINUTES.FIVE, staleTime: STALE.MINUTES.FIVE,
queryKey: profilesQueryKey(handles), queryKey: profilesQueryKey(handles),
@@ -88,6 +91,7 @@ export function useProfilesQuery({handles}: {handles: string[]}) {
} }
export function usePrefetchProfileQuery() { export function usePrefetchProfileQuery() {
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const prefetchProfileQuery = useCallback( const prefetchProfileQuery = useCallback(
async (did: string) => { async (did: string) => {
@@ -99,7 +103,7 @@ export function usePrefetchProfileQuery() {
}, },
}) })
}, },
[queryClient], [queryClient, getAgent],
) )
return prefetchProfileQuery return prefetchProfileQuery
} }
@@ -115,6 +119,7 @@ interface ProfileUpdateParams {
} }
export function useProfileUpdateMutation() { export function useProfileUpdateMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, ProfileUpdateParams>({ return useMutation<void, Error, ProfileUpdateParams>({
mutationFn: async ({ mutationFn: async ({
profile, profile,
@@ -154,6 +159,7 @@ export function useProfileUpdateMutation() {
return existing return existing
}) })
await whenAppViewReady( await whenAppViewReady(
getAgent,
profile.did, profile.did,
checkCommitted || checkCommitted ||
(res => { (res => {
@@ -255,6 +261,7 @@ function useProfileFollowMutation(
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
) { ) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation<{uri: string; cid: string}, Error, {did: string}>({ return useMutation<{uri: string; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => { mutationFn: async ({did}) => {
@@ -281,6 +288,7 @@ function useProfileFollowMutation(
function useProfileUnfollowMutation( function useProfileUnfollowMutation(
logContext: LogEvents['profile:unfollow']['logContext'], logContext: LogEvents['profile:unfollow']['logContext'],
) { ) {
const {getAgent} = useAgent()
return useMutation<void, Error, {did: string; followUri: string}>({ return useMutation<void, Error, {did: string; followUri: string}>({
mutationFn: async ({followUri}) => { mutationFn: async ({followUri}) => {
logEvent('profile:unfollow', {logContext}) logEvent('profile:unfollow', {logContext})
@@ -341,6 +349,7 @@ export function useProfileMuteMutationQueue(
function useProfileMuteMutation() { function useProfileMuteMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {did: string}>({ return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => { mutationFn: async ({did}) => {
await getAgent().mute(did) await getAgent().mute(did)
@@ -353,6 +362,7 @@ function useProfileMuteMutation() {
function useProfileUnmuteMutation() { function useProfileUnmuteMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {did: string}>({ return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => { mutationFn: async ({did}) => {
await getAgent().unmute(did) await getAgent().unmute(did)
@@ -419,6 +429,7 @@ export function useProfileBlockMutationQueue(
function useProfileBlockMutation() { function useProfileBlockMutation() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation<{uri: string; cid: string}, Error, {did: string}>({ return useMutation<{uri: string; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => { mutationFn: async ({did}) => {
@@ -439,6 +450,7 @@ function useProfileBlockMutation() {
function useProfileUnblockMutation() { function useProfileUnblockMutation() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation<void, Error, {did: string; blockUri: string}>({ return useMutation<void, Error, {did: string; blockUri: string}>({
mutationFn: async ({blockUri}) => { mutationFn: async ({blockUri}) => {
@@ -516,6 +528,7 @@ export function precacheThreadPostProfiles(
} }
async function whenAppViewReady( async function whenAppViewReady(
getAgent: () => BskyAgent,
actor: string, actor: string,
fn: (res: AppBskyActorGetProfile.Response) => boolean, fn: (res: AppBskyActorGetProfile.Response) => boolean,
) { ) {
+2 -1
View File
@@ -2,7 +2,7 @@ import {AppBskyActorDefs, AtUri} from '@atproto/api'
import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query' import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {profileBasicQueryKey as RQKEY_PROFILE_BASIC} from './profile' import {profileBasicQueryKey as RQKEY_PROFILE_BASIC} from './profile'
const RQKEY_ROOT = 'resolved-did' const RQKEY_ROOT = 'resolved-did'
@@ -24,6 +24,7 @@ export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult {
export function useResolveDidQuery(didOrHandle: string | undefined) { export function useResolveDidQuery(didOrHandle: string | undefined) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useQuery<string, Error>({ return useQuery<string, Error>({
staleTime: STALE.HOURS.ONE, staleTime: STALE.HOURS.ONE,
+2 -1
View File
@@ -6,7 +6,7 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {embedViewRecordToPostView, getEmbeddedPost} from './util' import {embedViewRecordToPostView, getEmbeddedPost} from './util'
const searchPostsQueryKeyRoot = 'search-posts' const searchPostsQueryKeyRoot = 'search-posts'
@@ -25,6 +25,7 @@ export function useSearchPostsQuery({
sort?: 'top' | 'latest' sort?: 'top' | 'latest'
enabled?: boolean enabled?: boolean
}) { }) {
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyFeedSearchPosts.OutputSchema, AppBskyFeedSearchPosts.OutputSchema,
Error, Error,
+2 -1
View File
@@ -2,12 +2,13 @@ import {AppBskyFeedGetSuggestedFeeds} from '@atproto/api'
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query' import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session' import {useAgent} from '#/state/session'
const suggestedFeedsQueryKeyRoot = 'suggestedFeeds' const suggestedFeedsQueryKeyRoot = 'suggestedFeeds'
export const suggestedFeedsQueryKey = [suggestedFeedsQueryKeyRoot] export const suggestedFeedsQueryKey = [suggestedFeedsQueryKeyRoot]
export function useSuggestedFeedsQuery() { export function useSuggestedFeedsQuery() {
const {getAgent} = useAgent()
return useInfiniteQuery< return useInfiniteQuery<
AppBskyFeedGetSuggestedFeeds.OutputSchema, AppBskyFeedGetSuggestedFeeds.OutputSchema,
Error, Error,
+3 -26
View File
@@ -1,4 +1,3 @@
import React from 'react'
import { import {
AppBskyActorDefs, AppBskyActorDefs,
AppBskyActorGetSuggestions, AppBskyActorGetSuggestions,
@@ -11,12 +10,11 @@ import {
QueryKey, QueryKey,
useInfiniteQuery, useInfiniteQuery,
useQuery, useQuery,
useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useModerationOpts} from '#/state/queries/preferences' import {useModerationOpts} from '#/state/queries/preferences'
import {getAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
const suggestedFollowsQueryKeyRoot = 'suggested-follows' const suggestedFollowsQueryKeyRoot = 'suggested-follows'
const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot] const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot]
@@ -29,6 +27,7 @@ const suggestedFollowsByActorQueryKey = (did: string) => [
export function useSuggestedFollowsQuery() { export function useSuggestedFollowsQuery() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
return useInfiniteQuery< return useInfiniteQuery<
@@ -79,6 +78,7 @@ export function useSuggestedFollowsQuery() {
} }
export function useSuggestedFollowsByActorQuery({did}: {did: string}) { export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
const {getAgent} = useAgent()
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({ return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
queryKey: suggestedFollowsByActorQueryKey(did), queryKey: suggestedFollowsByActorQueryKey(did),
queryFn: async () => { queryFn: async () => {
@@ -90,29 +90,6 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
}) })
} }
export function useGetSuggestedFollowersByActor() {
const queryClient = useQueryClient()
return React.useCallback(
async (actor: string) => {
const res = await queryClient.fetchQuery({
staleTime: STALE.MINUTES.ONE,
queryKey: suggestedFollowsByActorQueryKey(actor),
queryFn: async () => {
const res =
await getAgent().app.bsky.graph.getSuggestedFollowsByActor({
actor: actor,
})
return res.data
},
})
return res
},
[queryClient],
)
}
export function* findAllProfilesInQueryData( export function* findAllProfilesInQueryData(
queryClient: QueryClient, queryClient: QueryClient,
did: string, did: string,
+25 -11
View File
@@ -23,16 +23,14 @@ import {readLabelers} from './agent-config'
let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
/** function __getAgent() {
* NOTE
* Never hold on to the object returned by this function.
* Call `getAgent()` at the time of invocation to ensure
* that you never have a stale agent.
*/
export function getAgent() {
return __globalAgent return __globalAgent
} }
export function useAgent() {
return React.useMemo(() => ({getAgent: __getAgent}), [])
}
export type SessionAccount = persisted.PersistedAccount export type SessionAccount = persisted.PersistedAccount
export type SessionState = { export type SessionState = {
@@ -117,6 +115,7 @@ const ApiContext = React.createContext<ApiContext>({
}) })
function createPersistSessionHandler( function createPersistSessionHandler(
agent: BskyAgent,
account: SessionAccount, account: SessionAccount,
persistSessionCallback: (props: { persistSessionCallback: (props: {
expired: boolean expired: boolean
@@ -144,6 +143,7 @@ function createPersistSessionHandler(
email: session?.email || account.email, email: session?.email || account.email,
emailConfirmed: session?.emailConfirmed || account.emailConfirmed, emailConfirmed: session?.emailConfirmed || account.emailConfirmed,
deactivated: isSessionDeactivated(session?.accessJwt), deactivated: isSessionDeactivated(session?.accessJwt),
pdsUrl: agent.pdsUrl?.toString(),
/* /*
* Tokens are undefined if the session expires, or if creation fails for * Tokens are undefined if the session expires, or if creation fails for
@@ -276,12 +276,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
refreshJwt: agent.session.refreshJwt, refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt, accessJwt: agent.session.accessJwt,
deactivated, deactivated,
pdsUrl: agent.pdsUrl?.toString(),
} }
await configureModeration(agent, account) await configureModeration(agent, account)
agent.setPersistSessionHandler( agent.setPersistSessionHandler(
createPersistSessionHandler( createPersistSessionHandler(
agent,
account, account,
({expired, refreshedAccount}) => { ({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired) upsertAccount(refreshedAccount, expired)
@@ -327,12 +329,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
refreshJwt: agent.session.refreshJwt, refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt, accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt), deactivated: isSessionDeactivated(agent.session.accessJwt),
pdsUrl: agent.pdsUrl?.toString(),
} }
await configureModeration(agent, account) await configureModeration(agent, account)
agent.setPersistSessionHandler( agent.setPersistSessionHandler(
createPersistSessionHandler( createPersistSessionHandler(
agent,
account, account,
({expired, refreshedAccount}) => { ({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired) upsertAccount(refreshedAccount, expired)
@@ -379,16 +383,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logger.debug(`session: initSession`, {}, logger.DebugContext.session) logger.debug(`session: initSession`, {}, logger.DebugContext.session)
const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency') const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency')
const agent = new BskyAgent({ const agent = new BskyAgent({service: account.service})
service: account.service,
persistSession: createPersistSessionHandler( // restore the correct PDS URL if available
if (account.pdsUrl) {
agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
}
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account, account,
({expired, refreshedAccount}) => { ({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired) upsertAccount(refreshedAccount, expired)
}, },
{networkErrorCallback: clearCurrentAccount}, {networkErrorCallback: clearCurrentAccount},
), ),
}) )
// @ts-ignore // @ts-ignore
if (IS_DEV && isWeb) window.agent = agent if (IS_DEV && isWeb) window.agent = agent
await configureModeration(agent, account) await configureModeration(agent, account)
@@ -421,6 +433,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logger.debug(`session: attempting to reuse previous session`) logger.debug(`session: attempting to reuse previous session`)
agent.session = prevSession agent.session = prevSession
__globalAgent = agent __globalAgent = agent
await fetchingGates await fetchingGates
upsertAccount(account) upsertAccount(account)
@@ -498,6 +511,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
refreshJwt: agent.session.refreshJwt, refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt, accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt), deactivated: isSessionDeactivated(agent.session.accessJwt),
pdsUrl: agent.pdsUrl?.toString(),
} }
} }
}, },
@@ -0,0 +1,6 @@
import * as persisted from '#/state/persisted'
export function readLastActiveAccount() {
const {currentAccount, accounts} = persisted.get('session')
return accounts.find(a => a.did === currentAccount?.did)
}
-51
View File
@@ -1,51 +0,0 @@
import React from 'react'
import {SafeAreaView, Platform} from 'react-native'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {Welcome} from './onboarding/Welcome'
import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
import {RecommendedFollows} from './onboarding/RecommendedFollows'
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
import {useOnboardingState, useOnboardingDispatch} from '#/state/shell'
export function Onboarding() {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const onboardingState = useOnboardingState()
const onboardingDispatch = useOnboardingDispatch()
React.useEffect(() => {
setMinimalShellMode(true)
}, [setMinimalShellMode])
const next = () => onboardingDispatch({type: 'next'})
const skip = () => onboardingDispatch({type: 'skip'})
return (
<SafeAreaView
testID="onboardingView"
style={[
s.hContentRegion,
pal.view,
// @ts-ignore web only -esb
Platform.select({
web: {
height: '100vh',
},
}),
]}>
<ErrorBoundary>
{onboardingState.step === 'Welcome' && (
<Welcome skip={skip} next={next} />
)}
{onboardingState.step === 'RecommendedFeeds' && (
<RecommendedFeeds next={next} />
)}
{onboardingState.step === 'RecommendedFollows' && (
<RecommendedFollows next={next} />
)}
</ErrorBoundary>
</SafeAreaView>
)
}
@@ -1,211 +0,0 @@
import React from 'react'
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {Button} from 'view/com/util/forms/Button'
import {Mobile, TabletOrDesktop} from 'view/com/util/layouts/Breakpoints'
import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
import {Text} from 'view/com/util/text/Text'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {RecommendedFeedsItem} from './RecommendedFeedsItem'
type Props = {
next: () => void
}
export function RecommendedFeeds({next}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const {isTabletOrMobile} = useWebMediaQueries()
const {isLoading, data} = useSuggestedFeedsQuery()
const hasFeeds = data && data.pages[0].feeds.length
const title = (
<>
<Trans>
<Text
style={[
pal.textLight,
tdStyles.title1,
isTabletOrMobile && tdStyles.title1Small,
]}>
Choose your
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Recommended
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Feeds
</Text>
</Trans>
<Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
<Trans>
Feeds are created by users to curate content. Choose some feeds that
you find interesting.
</Trans>
</Text>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 20,
}}>
<Button onPress={next} testID="continueBtn">
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 2,
gap: 6,
}}>
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
<Trans>Next</Trans>
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
</Button>
</View>
</>
)
return (
<>
<TabletOrDesktop>
<TitleColumnLayout
testID="recommendedFeedsOnboarding"
title={title}
horizontal
titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
contentStyle={{paddingHorizontal: 0}}>
{hasFeeds ? (
<FlatList
data={data.pages[0].feeds}
renderItem={({item}) => <RecommendedFeedsItem item={item} />}
keyExtractor={item => item.uri}
style={{flex: 1}}
/>
) : isLoading ? (
<View>
<ActivityIndicator size="large" />
</View>
) : (
<ErrorMessage message={_(msg`Failed to load recommended feeds`)} />
)}
</TitleColumnLayout>
</TabletOrDesktop>
<Mobile>
<View style={[mStyles.container]} testID="recommendedFeedsOnboarding">
<ViewHeader
title={_(msg`Recommended Feeds`)}
showBackButton={false}
showOnDesktop
/>
<Text type="lg-medium" style={[pal.text, mStyles.header]}>
<Trans>
Check out some recommended feeds. Tap + to add them to your list
of pinned feeds.
</Trans>
</Text>
{hasFeeds ? (
<FlatList
data={data.pages[0].feeds}
renderItem={({item}) => <RecommendedFeedsItem item={item} />}
keyExtractor={item => item.uri}
style={{flex: 1}}
showsVerticalScrollIndicator={false}
/>
) : isLoading ? (
<View style={{flex: 1}}>
<ActivityIndicator size="large" />
</View>
) : (
<View style={{flex: 1}}>
<ErrorMessage
message={_(msg`Failed to load recommended feeds`)}
/>
</View>
)}
<Button
onPress={next}
label={_(msg`Continue`)}
testID="continueBtn"
style={mStyles.button}
labelStyle={mStyles.buttonText}
/>
</View>
</Mobile>
</>
)
}
const tdStyles = StyleSheet.create({
container: {
flex: 1,
marginHorizontal: 16,
justifyContent: 'space-between',
},
title1: {
fontSize: 36,
fontWeight: '800',
textAlign: 'right',
},
title1Small: {
fontSize: 24,
},
title2: {
fontSize: 58,
fontWeight: '800',
textAlign: 'right',
},
title2Small: {
fontSize: 36,
},
description: {
maxWidth: 400,
marginTop: 10,
marginLeft: 'auto',
textAlign: 'right',
},
})
const mStyles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
},
header: {
marginBottom: 16,
marginHorizontal: 16,
},
button: {
marginBottom: 16,
marginHorizontal: 16,
marginTop: 16,
alignItems: 'center',
},
buttonText: {
textAlign: 'center',
fontSize: 18,
paddingVertical: 4,
},
})
@@ -1,172 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedDefs, RichText as BskRichText} from '@atproto/api'
import {Text} from 'view/com/util/text/Text'
import {RichText} from 'view/com/util/text/RichText'
import {Button} from 'view/com/util/forms/Button'
import {UserAvatar} from 'view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast'
import {HeartIcon} from 'lib/icons'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {sanitizeHandle} from 'lib/strings/handles'
import {
usePreferencesQuery,
usePinFeedMutation,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {logger} from '#/logger'
import {useAnalytics} from '#/lib/analytics/analytics'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function RecommendedFeedsItem({
item,
}: {
item: AppBskyFeedDefs.GeneratorView
}) {
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {
mutateAsync: pinFeed,
variables: pinnedFeed,
reset: resetPinFeed,
} = usePinFeedMutation()
const {
mutateAsync: removeFeed,
variables: removedFeed,
reset: resetRemoveFeed,
} = useRemoveFeedMutation()
const {track} = useAnalytics()
if (!item || !preferences) return null
const isPinned =
!removedFeed?.uri &&
(pinnedFeed?.uri || preferences.feeds.saved.includes(item.uri))
const onToggle = async () => {
if (isPinned) {
try {
await removeFeed({uri: item.uri})
resetRemoveFeed()
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {message: e})
}
} else {
try {
await pinFeed({uri: item.uri})
resetPinFeed()
track('Onboarding:CustomFeedAdded')
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to pin feed', {message: e})
}
}
}
return (
<View testID={`feed-${item.displayName}`}>
<View
style={[
pal.border,
{
flex: isMobile ? 1 : undefined,
flexDirection: 'row',
gap: 18,
maxWidth: isMobile ? undefined : 670,
borderRightWidth: isMobile ? undefined : 1,
paddingHorizontal: 24,
paddingVertical: isMobile ? 12 : 24,
borderTopWidth: 1,
},
]}>
<View style={{marginTop: 2}}>
<UserAvatar type="algo" size={42} avatar={item.avatar} />
</View>
<View style={{flex: isMobile ? 1 : undefined}}>
<Text
type="2xl-bold"
numberOfLines={1}
style={[pal.text, {fontSize: 19}]}>
{item.displayName}
</Text>
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
<Trans>by {sanitizeHandle(item.creator.handle, '@')}</Trans>
</Text>
{item.description ? (
<RichText
type="xl"
style={[
pal.text,
{
flex: isMobile ? 1 : undefined,
maxWidth: 550,
marginBottom: 18,
},
]}
richText={new BskRichText({text: item.description || ''})}
numberOfLines={6}
/>
) : null}
<View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
<Button
type="inverted"
style={{paddingVertical: 6}}
onPress={onToggle}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingRight: 2,
gap: 6,
}}>
{isPinned ? (
<>
<FontAwesomeIcon
icon="check"
size={16}
color={pal.colors.textInverted}
/>
<Text type="lg-medium" style={pal.textInverted}>
<Trans>Added</Trans>
</Text>
</>
) : (
<>
<FontAwesomeIcon
icon="plus"
size={16}
color={pal.colors.textInverted}
/>
<Text type="lg-medium" style={pal.textInverted}>
<Trans>Add</Trans>
</Text>
</>
)}
</View>
</Button>
<View style={{flexDirection: 'row', gap: 4}}>
<HeartIcon
size={16}
strokeWidth={2.5}
style={[pal.textLight, {position: 'relative', top: 2}]}
/>
<Text type="lg-medium" style={[pal.text, pal.textLight]}>
{item.likeCount || 0}
</Text>
</View>
</View>
</View>
</View>
</View>
)
}
@@ -1,272 +0,0 @@
import React from 'react'
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/queries/preferences'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Button} from 'view/com/util/forms/Button'
import {Mobile, TabletOrDesktop} from 'view/com/util/layouts/Breakpoints'
import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
import {Text} from 'view/com/util/text/Text'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {RecommendedFollowsItem} from './RecommendedFollowsItem'
type Props = {
next: () => void
}
export function RecommendedFollows({next}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const {isTabletOrMobile} = useWebMediaQueries()
const {data: suggestedFollows} = useSuggestedFollowsQuery()
const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{
[did: string]: AppBskyActorDefs.ProfileView[]
}>({})
const existingDids = React.useRef<string[]>([])
const moderationOpts = useModerationOpts()
const title = (
<>
<Trans>
<Text
style={[
pal.textLight,
tdStyles.title1,
isTabletOrMobile && tdStyles.title1Small,
]}>
Follow some
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Recommended
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Users
</Text>
</Trans>
<Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
<Trans>
Follow some users to get started. We can recommend you more users
based on who you find interesting.
</Trans>
</Text>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 20,
}}>
<Button onPress={next} testID="continueBtn">
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 2,
gap: 6,
}}>
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
<Trans context="action">Done</Trans>
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
</Button>
</View>
</>
)
const suggestions = React.useMemo(() => {
if (!suggestedFollows) return []
const additional = Object.entries(additionalSuggestions)
const items = suggestedFollows.pages.flatMap(page => page.actors)
outer: while (additional.length) {
const additionalAccount = additional.shift()
if (!additionalAccount) break
const [followedUser, relatedAccounts] = additionalAccount
for (let i = 0; i < items.length; i++) {
if (items[i].did === followedUser) {
items.splice(i + 1, 0, ...relatedAccounts)
continue outer
}
}
}
existingDids.current = items.map(i => i.did)
return items
}, [suggestedFollows, additionalSuggestions])
const onFollowStateChange = React.useCallback(
async ({following, did}: {following: boolean; did: string}) => {
if (following) {
try {
const {suggestions: results} = await getSuggestedFollowsByActor(did)
if (results.length) {
const deduped = results.filter(
r => !existingDids.current.find(did => did === r.did),
)
setAdditionalSuggestions(s => ({
...s,
[did]: deduped.slice(0, 3),
}))
}
} catch (e) {
logger.error('RecommendedFollows: failed to get suggestions', {
message: e,
})
}
}
// not handling the unfollow case
},
[existingDids, getSuggestedFollowsByActor, setAdditionalSuggestions],
)
return (
<>
<TabletOrDesktop>
<TitleColumnLayout
testID="recommendedFollowsOnboarding"
title={title}
horizontal
titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
contentStyle={{paddingHorizontal: 0}}>
{!suggestedFollows || !moderationOpts ? (
<ActivityIndicator size="large" />
) : (
<FlatList
data={suggestions}
renderItem={({item}) => (
<RecommendedFollowsItem
profile={item}
onFollowStateChange={onFollowStateChange}
moderation={moderateProfile(item, moderationOpts)}
/>
)}
keyExtractor={item => item.did}
style={{flex: 1}}
/>
)}
</TitleColumnLayout>
</TabletOrDesktop>
<Mobile>
<View style={[mStyles.container]} testID="recommendedFollowsOnboarding">
<View>
<ViewHeader
title={_(msg`Recommended Users`)}
showBackButton={false}
showOnDesktop
/>
<Text type="lg-medium" style={[pal.text, mStyles.header]}>
<Trans>
Check out some recommended users. Follow them to see similar
users.
</Trans>
</Text>
</View>
{!suggestedFollows || !moderationOpts ? (
<ActivityIndicator size="large" />
) : (
<FlatList
data={suggestions}
renderItem={({item}) => (
<RecommendedFollowsItem
profile={item}
onFollowStateChange={onFollowStateChange}
moderation={moderateProfile(item, moderationOpts)}
/>
)}
keyExtractor={item => item.did}
style={{flex: 1}}
showsVerticalScrollIndicator={false}
/>
)}
<Button
onPress={next}
label={_(msg`Continue`)}
testID="continueBtn"
style={mStyles.button}
labelStyle={mStyles.buttonText}
/>
</View>
</Mobile>
</>
)
}
const tdStyles = StyleSheet.create({
container: {
flex: 1,
marginHorizontal: 16,
justifyContent: 'space-between',
},
title1: {
fontSize: 36,
fontWeight: '800',
textAlign: 'right',
},
title1Small: {
fontSize: 24,
},
title2: {
fontSize: 58,
fontWeight: '800',
textAlign: 'right',
},
title2Small: {
fontSize: 36,
},
description: {
maxWidth: 400,
marginTop: 10,
marginLeft: 'auto',
textAlign: 'right',
},
})
const mStyles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
},
header: {
marginBottom: 16,
marginHorizontal: 16,
},
button: {
marginBottom: 16,
marginHorizontal: 16,
marginTop: 16,
alignItems: 'center',
},
buttonText: {
textAlign: 'center',
fontSize: 18,
paddingVertical: 4,
},
})
@@ -1,202 +0,0 @@
import React from 'react'
import {View, StyleSheet, ActivityIndicator} from 'react-native'
import {ModerationDecision, AppBskyActorDefs} from '@atproto/api'
import {Button} from '#/view/com/util/forms/Button'
import {usePalette} from 'lib/hooks/usePalette'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {s} from 'lib/styles'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {Text} from 'view/com/util/text/Text'
import Animated, {FadeInRight} from 'react-native-reanimated'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useAnalytics} from 'lib/analytics/analytics'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {logger} from '#/logger'
type Props = {
profile: AppBskyActorDefs.ProfileViewBasic
moderation: ModerationDecision
onFollowStateChange: (props: {
did: string
following: boolean
}) => Promise<void>
}
export function RecommendedFollowsItem({
profile,
moderation,
onFollowStateChange,
}: React.PropsWithChildren<Props>) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const shadowedProfile = useProfileShadow(profile)
return (
<Animated.View
entering={FadeInRight}
style={[
styles.cardContainer,
pal.view,
pal.border,
{
maxWidth: isMobile ? undefined : 670,
borderRightWidth: isMobile ? undefined : 1,
},
]}>
<ProfileCard
key={profile.did}
profile={shadowedProfile}
onFollowStateChange={onFollowStateChange}
moderation={moderation}
/>
</Animated.View>
)
}
function ProfileCard({
profile,
onFollowStateChange,
moderation,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
moderation: ModerationDecision
onFollowStateChange: (props: {
did: string
following: boolean
}) => Promise<void>
}) {
const {track} = useAnalytics()
const pal = usePalette('default')
const {_} = useLingui()
const [addingMoreSuggestions, setAddingMoreSuggestions] =
React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'RecommendedFollowsItem',
)
const onToggleFollow = React.useCallback(async () => {
try {
if (profile.viewer?.following) {
await queueUnfollow()
} else {
setAddingMoreSuggestions(true)
await queueFollow()
await onFollowStateChange({did: profile.did, following: true})
setAddingMoreSuggestions(false)
track('Onboarding:SuggestedFollowFollowed')
}
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('RecommendedFollows: failed to toggle following', {
message: e,
})
}
} finally {
setAddingMoreSuggestions(false)
}
}, [
profile,
queueFollow,
queueUnfollow,
setAddingMoreSuggestions,
track,
onFollowStateChange,
])
return (
<View style={styles.card}>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
<View style={styles.layoutContent}>
<Text
type="2xl-bold"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<Text type="xl" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
<Button
type={profile.viewer?.following ? 'default' : 'inverted'}
labelStyle={styles.followButton}
onPress={onToggleFollow}
label={profile.viewer?.following ? _(msg`Unfollow`) : _(msg`Follow`)}
/>
</View>
{profile.description ? (
<View style={styles.details}>
<Text type="lg" style={pal.text} numberOfLines={4}>
{profile.description as string}
</Text>
</View>
) : undefined}
{addingMoreSuggestions ? (
<View style={styles.addingMoreContainer}>
<ActivityIndicator size="small" color={pal.colors.text} />
<Text style={[pal.text]}>
<Trans>Finding similar accounts...</Trans>
</Text>
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create({
cardContainer: {
borderTopWidth: 1,
},
card: {
paddingHorizontal: 10,
},
layout: {
flexDirection: 'row',
alignItems: 'center',
},
layoutAvi: {
width: 54,
paddingLeft: 4,
paddingTop: 8,
paddingBottom: 10,
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
details: {
paddingLeft: 54,
paddingRight: 10,
paddingBottom: 10,
},
addingMoreContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 54,
paddingTop: 4,
paddingBottom: 12,
gap: 4,
},
followButton: {
fontSize: 16,
},
})
-10
View File
@@ -1,10 +0,0 @@
import 'react'
import {withBreakpoints} from 'view/com/util/layouts/withBreakpoints'
import {WelcomeDesktop} from './WelcomeDesktop'
import {WelcomeMobile} from './WelcomeMobile'
export const Welcome = withBreakpoints(
WelcomeMobile,
WelcomeDesktop,
WelcomeDesktop,
)
@@ -1,126 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {useMediaQuery} from 'react-responsive'
import {Text} from 'view/com/util/text/Text'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
import {Button} from 'view/com/util/forms/Button'
import {Trans} from '@lingui/macro'
type Props = {
next: () => void
skip: () => void
}
export function WelcomeDesktop({next}: Props) {
const pal = usePalette('default')
const horizontal = useMediaQuery({minWidth: 1300})
const title = (
<Trans>
<Text
style={[
pal.textLight,
{
fontSize: 36,
fontWeight: '800',
textAlign: horizontal ? 'right' : 'left',
},
]}>
Welcome to
</Text>
<Text
style={[
pal.link,
{
fontSize: 72,
fontWeight: '800',
textAlign: horizontal ? 'right' : 'left',
},
]}>
Bluesky
</Text>
</Trans>
)
return (
<TitleColumnLayout
testID="welcomeOnboarding"
title={title}
horizontal={horizontal}
titleStyle={horizontal ? {paddingBottom: 160} : undefined}>
<View style={[styles.row]}>
<FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="xl-bold" style={[pal.text]}>
<Trans>Bluesky is public.</Trans>
</Text>
<Text type="xl" style={[pal.text, s.pt2]}>
<Trans>
Your posts, likes, and blocks are public. Mutes are private.
</Trans>
</Text>
</View>
</View>
<View style={[styles.row]}>
<FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="xl-bold" style={[pal.text]}>
<Trans>Bluesky is open.</Trans>
</Text>
<Text type="xl" style={[pal.text, s.pt2]}>
<Trans>Never lose access to your followers and data.</Trans>
</Text>
</View>
</View>
<View style={[styles.row]}>
<FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="xl-bold" style={[pal.text]}>
<Trans>Bluesky is flexible.</Trans>
</Text>
<Text type="xl" style={[pal.text, s.pt2]}>
<Trans>
Choose the algorithms that power your experience with custom
feeds.
</Trans>
</Text>
</View>
</View>
<View style={styles.spacer} />
<View style={{flexDirection: 'row'}}>
<Button onPress={next} testID="continueBtn">
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 2,
gap: 6,
}}>
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
<Trans context="action">Next</Trans>
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
</Button>
</View>
</TitleColumnLayout>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
columnGap: 20,
alignItems: 'center',
marginVertical: 20,
},
rowText: {
flex: 1,
},
spacer: {
height: 20,
},
})
@@ -1,136 +0,0 @@
import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Button} from 'view/com/util/forms/Button'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
type Props = {
next: () => void
skip: () => void
}
export function WelcomeMobile({next, skip}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
return (
<View style={[styles.container]} testID="welcomeOnboarding">
<ViewHeader
showOnDesktop
showBorder={false}
showBackButton={false}
title=""
renderButton={() => {
return (
<Pressable
accessibilityRole="button"
style={[s.flexRow, s.alignCenter]}
onPress={skip}>
<Text style={[pal.link]}>
<Trans>Skip</Trans>
</Text>
<FontAwesomeIcon
icon={'chevron-right'}
size={14}
color={pal.colors.link}
/>
</Pressable>
)
}}
/>
<View>
<Text style={[pal.text, styles.title]}>
<Trans>
Welcome to{' '}
<Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
</Trans>
</Text>
<View style={styles.spacer} />
<View style={[styles.row]}>
<FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="lg-bold" style={[pal.text]}>
<Trans>Bluesky is public.</Trans>
</Text>
<Text type="lg-thin" style={[pal.text, s.pt2]}>
<Trans>
Your posts, likes, and blocks are public. Mutes are private.
</Trans>
</Text>
</View>
</View>
<View style={[styles.row]}>
<FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="lg-bold" style={[pal.text]}>
<Trans>Bluesky is open.</Trans>
</Text>
<Text type="lg-thin" style={[pal.text, s.pt2]}>
<Trans>Never lose access to your followers and data.</Trans>
</Text>
</View>
</View>
<View style={[styles.row]}>
<FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="lg-bold" style={[pal.text]}>
<Trans>Bluesky is flexible.</Trans>
</Text>
<Text type="lg-thin" style={[pal.text, s.pt2]}>
<Trans>
Choose the algorithms that power your experience with custom
feeds.
</Trans>
</Text>
</View>
</View>
</View>
<Button
onPress={next}
label={_(msg`Continue`)}
testID="continueBtn"
style={[styles.buttonContainer]}
labelStyle={styles.buttonText}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
marginBottom: 60,
marginHorizontal: 16,
justifyContent: 'space-between',
},
title: {
fontSize: 42,
fontWeight: '800',
},
row: {
flexDirection: 'row',
columnGap: 20,
alignItems: 'center',
marginVertical: 20,
},
rowText: {
flex: 1,
},
spacer: {
height: 20,
},
buttonContainer: {
alignItems: 'center',
},
buttonText: {
textAlign: 'center',
fontSize: 18,
marginVertical: 4,
},
})
+10 -4
View File
@@ -32,7 +32,7 @@ import {
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor' import {Gif} from '#/state/queries/tenor'
import {ThreadgateSetting} from '#/state/queries/threadgate' import {ThreadgateSetting} from '#/state/queries/threadgate'
import {getAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
@@ -53,7 +53,7 @@ import {atoms as a} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed' import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
@@ -83,6 +83,7 @@ export const ComposePost = observer(function ComposePost({
imageUris: initImageUris, imageUris: initImageUris,
}: Props) { }: Props) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
const {isModalActive} = useModals() const {isModalActive} = useModals()
const {closeComposer} = useComposerControls() const {closeComposer} = useComposerControls()
@@ -483,8 +484,13 @@ export const ComposePost = observer(function ComposePost({
/> />
)} )}
{quote ? ( {quote ? (
<View style={[s.mt5, isWeb && s.mb10, {pointerEvents: 'none'}]}> <View style={[s.mt5, isWeb && s.mb10]}>
<QuoteEmbed quote={quote} /> <View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View> </View>
) : undefined} ) : undefined}
</ScrollView> </ScrollView>
+16 -20
View File
@@ -28,8 +28,8 @@ import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {isIOS} from 'platform/detection' import {isIOS} from 'platform/detection'
import { import {
addLinkCardIfNecessary, LinkFacetMatch,
findIndexInText, suggestLinkCardUri,
} from 'view/com/composer/text-input/text-input-util' } from 'view/com/composer/text-input/text-input-util'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {Autocomplete} from './mobile/Autocomplete' import {Autocomplete} from './mobile/Autocomplete'
@@ -73,7 +73,6 @@ export const TextInput = forwardRef(function TextInputImpl(
const theme = useTheme() const theme = useTheme()
const [autocompletePrefix, setAutocompletePrefix] = useState('') const [autocompletePrefix, setAutocompletePrefix] = useState('')
const prevLength = React.useRef(richtext.length) const prevLength = React.useRef(richtext.length)
const prevAddedLinks = useRef(new Set<string>())
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
focus: () => textInput.current?.focus(), focus: () => textInput.current?.focus(),
@@ -83,6 +82,8 @@ export const TextInput = forwardRef(function TextInputImpl(
getCursorPosition: () => undefined, // Not implemented on native getCursorPosition: () => undefined, // Not implemented on native
})) }))
const pastSuggestedUris = useRef(new Set<string>())
const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
const onChangeText = useCallback( const onChangeText = useCallback(
(newText: string) => { (newText: string) => {
/* /*
@@ -112,6 +113,7 @@ export const TextInput = forwardRef(function TextInputImpl(
setAutocompletePrefix('') setAutocompletePrefix('')
} }
const nextDetectedUris = new Map<string, LinkFacetMatch>()
if (newRt.facets) { if (newRt.facets) {
for (const facet of newRt.facets) { for (const facet of newRt.facets) {
for (const feature of facet.features) { for (const feature of facet.features) {
@@ -130,32 +132,26 @@ export const TextInput = forwardRef(function TextInputImpl(
onPhotoPasted(res.path) onPhotoPasted(res.path)
} }
} else { } else {
const cursorLocation = textInputSelection.current.end nextDetectedUris.set(feature.uri, {facet, rt: newRt})
addLinkCardIfNecessary({
uri: feature.uri,
newText,
cursorLocation,
mayBePaste,
onNewLink,
prevAddedLinks: prevAddedLinks.current,
})
} }
} }
} }
} }
} }
const suggestedUri = suggestLinkCardUri(
for (const uri of prevAddedLinks.current.keys()) { mayBePaste,
if (findIndexInText(uri, newText) === -1) { nextDetectedUris,
prevAddedLinks.current.delete(uri) prevDetectedUris.current,
} pastSuggestedUris.current,
)
prevDetectedUris.current = nextDetectedUris
if (suggestedUri) {
onNewLink(suggestedUri)
} }
prevLength.current = newText.length prevLength.current = newText.length
}, 1) }, 1)
}, },
[setRichText, autocompletePrefix, onPhotoPasted, prevAddedLinks, onNewLink], [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
) )
const onPaste = useCallback( const onPaste = useCallback(
@@ -19,8 +19,8 @@ import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {blobToDataUri, isUriImage} from 'lib/media/util' import {blobToDataUri, isUriImage} from 'lib/media/util'
import { import {
addLinkCardIfNecessary, LinkFacetMatch,
findIndexInText, suggestLinkCardUri,
} from 'view/com/composer/text-input/text-input-util' } from 'view/com/composer/text-input/text-input-util'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {Text} from '../../util/text/Text' import {Text} from '../../util/text/Text'
@@ -61,9 +61,6 @@ export const TextInput = React.forwardRef(function TextInputImpl(
ref, ref,
) { ) {
const autocomplete = useActorAutocompleteFn() const autocomplete = useActorAutocompleteFn()
const prevLength = React.useRef(0)
const prevAddedLinks = useRef(new Set<string>())
const pal = usePalette('default') const pal = usePalette('default')
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
@@ -144,6 +141,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
} }
}, [setIsDropping]) }, [setIsDropping])
const pastSuggestedUris = useRef(new Set<string>())
const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
const editor = useEditor( const editor = useEditor(
{ {
extensions, extensions,
@@ -185,42 +184,34 @@ export const TextInput = React.forwardRef(function TextInputImpl(
}, },
onUpdate({editor: editorProp}) { onUpdate({editor: editorProp}) {
const json = editorProp.getJSON() const json = editorProp.getJSON()
const newText = editorJsonToText(json).trimEnd() const newText = editorJsonToText(json)
const mayBePaste = newText.length > prevLength.current + 1 const isPaste = window.event?.type === 'paste'
const newRt = new RichText({text: newText}) const newRt = new RichText({text: newText})
newRt.detectFacetsWithoutResolution() newRt.detectFacetsWithoutResolution()
setRichText(newRt) setRichText(newRt)
const nextDetectedUris = new Map<string, LinkFacetMatch>()
if (newRt.facets) { if (newRt.facets) {
for (const facet of newRt.facets) { for (const facet of newRt.facets) {
for (const feature of facet.features) { for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) { if (AppBskyRichtextFacet.isLink(feature)) {
// The TipTap editor shows the position as being one character ahead, as if the start index is 1. nextDetectedUris.set(feature.uri, {facet, rt: newRt})
// Subtracting 1 from the pos gives us the same behavior as the native impl.
let cursorLocation = editor?.state.selection.$anchor.pos ?? 1
cursorLocation -= 1
addLinkCardIfNecessary({
uri: feature.uri,
newText,
cursorLocation,
mayBePaste,
onNewLink,
prevAddedLinks: prevAddedLinks.current,
})
} }
} }
} }
} }
for (const uri of prevAddedLinks.current.keys()) { const suggestedUri = suggestLinkCardUri(
if (findIndexInText(uri, newText) === -1) { isPaste,
prevAddedLinks.current.delete(uri) nextDetectedUris,
} prevDetectedUris.current,
pastSuggestedUris.current,
)
prevDetectedUris.current = nextDetectedUris
if (suggestedUri) {
onNewLink(suggestedUri)
} }
prevLength.current = newText.length
}, },
}, },
[modeClass], [modeClass],
@@ -277,15 +268,29 @@ export const TextInput = React.forwardRef(function TextInputImpl(
) )
}) })
function editorJsonToText(json: JSONContent): string { function editorJsonToText(
json: JSONContent,
isLastDocumentChild: boolean = false,
): string {
let text = '' let text = ''
if (json.type === 'doc' || json.type === 'paragraph') { if (json.type === 'doc') {
if (json.content?.length) { if (json.content?.length) {
for (const node of json.content) { for (let i = 0; i < json.content.length; i++) {
const node = json.content[i]
const isLastNode = i === json.content.length - 1
text += editorJsonToText(node, isLastNode)
}
}
} else if (json.type === 'paragraph') {
if (json.content?.length) {
for (let i = 0; i < json.content.length; i++) {
const node = json.content[i]
text += editorJsonToText(node) text += editorJsonToText(node)
} }
} }
text += '\n' if (!isLastDocumentChild) {
text += '\n'
}
} else if (json.type === 'hardBreak') { } else if (json.type === 'hardBreak') {
text += '\n' text += '\n'
} else if (json.type === 'text') { } else if (json.type === 'text') {
@@ -1,41 +1,85 @@
export function addLinkCardIfNecessary({ import {AppBskyRichtextFacet, RichText} from '@atproto/api'
uri,
newText,
cursorLocation,
mayBePaste,
onNewLink,
prevAddedLinks,
}: {
uri: string
newText: string
cursorLocation: number
mayBePaste: boolean
onNewLink: (uri: string) => void
prevAddedLinks: Set<string>
}) {
// It would be cool if we could just use facet.index.byteEnd, but you know... *upside down smiley*
const lastCharacterPosition = findIndexInText(uri, newText) + uri.length
// If the text being added is not from a paste, then we should only check if the cursor is one export type LinkFacetMatch = {
// position ahead of the last character. However, if it is a paste we need to check both if it's rt: RichText
// the same position _or_ one position ahead. That is because iOS will add a space after a paste if facet: AppBskyRichtextFacet.Main
// pasting into the middle of a sentence! }
const cursorLocationIsOkay =
cursorLocation === lastCharacterPosition + 1 || mayBePaste
// Checking previouslyAddedLinks keeps a card from getting added over and over i.e. export function suggestLinkCardUri(
// Link card added -> Remove link card -> Press back space -> Press space -> Link card added -> and so on mayBePaste: boolean,
nextDetectedUris: Map<string, LinkFacetMatch>,
// We use the isValidUrl regex below because we don't want to add embeds only if the url is valid, i.e. prevDetectedUris: Map<string, LinkFacetMatch>,
// http://facebook is a valid url, but that doesn't mean we want to embed it. We should only embed if pastSuggestedUris: Set<string>,
// the url is a valid url _and_ domain. new URL() won't work for this check. ): string | undefined {
const shouldCheck = const suggestedUris = new Set<string>()
cursorLocationIsOkay && !prevAddedLinks.has(uri) && isValidUrlAndDomain(uri) for (const [uri, nextMatch] of nextDetectedUris) {
if (!isValidUrlAndDomain(uri)) {
if (shouldCheck) { continue
onNewLink(uri) }
prevAddedLinks.add(uri) if (pastSuggestedUris.has(uri)) {
// Don't suggest already added or already dismissed link cards.
continue
}
if (mayBePaste) {
// Immediately add the pasted link without waiting to type more.
suggestedUris.add(uri)
continue
}
const prevMatch = prevDetectedUris.get(uri)
if (!prevMatch) {
// If the same exact link wasn't already detected during the last keystroke,
// it means you're probably still typing it. Disregard until it stabilizes.
continue
}
const prevTextAfterUri = prevMatch.rt.unicodeText.slice(
prevMatch.facet.index.byteEnd,
)
const nextTextAfterUri = nextMatch.rt.unicodeText.slice(
nextMatch.facet.index.byteEnd,
)
if (prevTextAfterUri === nextTextAfterUri) {
// The text you're editing is before the link, e.g.
// "abc google.com" -> "abcd google.com".
// This is a good time to add the link.
suggestedUris.add(uri)
continue
}
if (/^\s/m.test(nextTextAfterUri)) {
// The link is followed by a space, e.g.
// "google.com" -> "google.com " or
// "google.com." -> "google.com ".
// This is a clear indicator we can linkify it.
suggestedUris.add(uri)
continue
}
if (
/^[)]?[.,:;!?)](\s|$)/m.test(prevTextAfterUri) &&
/^[)]?[.,:;!?)]\s/m.test(nextTextAfterUri)
) {
// The link was *already* being followed by punctuation,
// and now it's followed both by punctuation and a space.
// This means you're typing after punctuation, e.g.
// "google.com." -> "google.com. " or
// "google.com.foo" -> "google.com. foo".
// This means you're not typing the link anymore, so we can linkify it.
suggestedUris.add(uri)
continue
}
} }
for (const uri of pastSuggestedUris) {
if (!nextDetectedUris.has(uri)) {
// If a link is no longer detected, it's eligible for suggestions next time.
pastSuggestedUris.delete(uri)
}
}
let suggestedUri: string | undefined
if (suggestedUris.size > 0) {
suggestedUri = Array.from(suggestedUris)[0]
pastSuggestedUris.add(suggestedUri)
}
return suggestedUri
} }
// https://stackoverflow.com/questions/8667070/javascript-regular-expression-to-validate-url // https://stackoverflow.com/questions/8667070/javascript-regular-expression-to-validate-url
@@ -46,14 +90,3 @@ function isValidUrlAndDomain(value: string) {
value, value,
) )
} }
export function findIndexInText(term: string, text: string) {
// This should find patterns like:
// HELLO SENTENCE http://google.com/ HELLO
// HELLO SENTENCE http://google.com HELLO
// http://google.com/ HELLO.
// http://google.com/.
const pattern = new RegExp(`\\b(${term})(?![/w])`, 'i')
const match = pattern.exec(text)
return match ? match.index : -1
}
@@ -14,11 +14,11 @@
* the facet-set. * the facet-set.
*/ */
import {Mark} from '@tiptap/core'
import {Plugin, PluginKey} from '@tiptap/pm/state'
import {Node as ProsemirrorNode} from '@tiptap/pm/model'
import {Decoration, DecorationSet} from '@tiptap/pm/view'
import {URL_REGEX} from '@atproto/api' import {URL_REGEX} from '@atproto/api'
import {Mark} from '@tiptap/core'
import {Node as ProsemirrorNode} from '@tiptap/pm/model'
import {Plugin, PluginKey} from '@tiptap/pm/state'
import {Decoration, DecorationSet} from '@tiptap/pm/view'
import {isValidDomain} from 'lib/strings/url-helpers' import {isValidDomain} from 'lib/strings/url-helpers'
@@ -91,7 +91,7 @@ function iterateUris(str: string, cb: (from: number, to: number) => void) {
uri = `https://${uri}` uri = `https://${uri}`
} }
let from = str.indexOf(match[2], match.index) let from = str.indexOf(match[2], match.index)
let to = from + match[2].length + 1 let to = from + match[2].length
// strip ending puncuation // strip ending puncuation
if (/[.,;!?]$/.test(uri)) { if (/[.,;!?]$/.test(uri)) {
uri = uri.slice(0, -1) uri = uri.slice(0, -1)
@@ -1,12 +1,14 @@
import {useState, useEffect} from 'react' import {useEffect, useState} from 'react'
import {useAgent} from '#/state/session'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {getLinkMeta} from 'lib/link-meta/link-meta' import {getLinkMeta} from 'lib/link-meta/link-meta'
import {ComposerOpts} from 'state/shell/composer' import {ComposerOpts} from 'state/shell/composer'
import {getAgent} from '#/state/session'
export function useExternalLinkFetch({}: { export function useExternalLinkFetch({}: {
setQuote: (opts: ComposerOpts['quote']) => void setQuote: (opts: ComposerOpts['quote']) => void
}) { }) {
const {getAgent} = useAgent()
const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
undefined, undefined,
) )
@@ -39,7 +41,7 @@ export function useExternalLinkFetch({}: {
}) })
} }
return cleanup return cleanup
}, [extLink]) }, [extLink, getAgent])
return {extLink, setExtLink} return {extLink, setExtLink}
} }
+13 -11
View File
@@ -1,24 +1,25 @@
import {useState, useEffect} from 'react' import {useEffect, useState} from 'react'
import {ImageModel} from 'state/models/media/image'
import {logger} from '#/logger'
import {useFetchDid} from '#/state/queries/handle'
import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {getLinkMeta} from 'lib/link-meta/link-meta' import {POST_IMG_MAX} from 'lib/constants'
import { import {
getPostAsQuote,
getFeedAsEmbed, getFeedAsEmbed,
getListAsEmbed, getListAsEmbed,
getPostAsQuote,
} from 'lib/link-meta/bsky' } from 'lib/link-meta/bsky'
import {getLinkMeta} from 'lib/link-meta/link-meta'
import {downloadAndResize} from 'lib/media/manip' import {downloadAndResize} from 'lib/media/manip'
import { import {
isBskyPostUrl,
isBskyCustomFeedUrl, isBskyCustomFeedUrl,
isBskyListUrl, isBskyListUrl,
isBskyPostUrl,
} from 'lib/strings/url-helpers' } from 'lib/strings/url-helpers'
import {ImageModel} from 'state/models/media/image'
import {ComposerOpts} from 'state/shell/composer' import {ComposerOpts} from 'state/shell/composer'
import {POST_IMG_MAX} from 'lib/constants'
import {logger} from '#/logger'
import {getAgent} from '#/state/session'
import {useGetPost} from '#/state/queries/post'
import {useFetchDid} from '#/state/queries/handle'
export function useExternalLinkFetch({ export function useExternalLinkFetch({
setQuote, setQuote,
@@ -30,6 +31,7 @@ export function useExternalLinkFetch({
) )
const getPost = useGetPost() const getPost = useGetPost()
const fetchDid = useFetchDid() const fetchDid = useFetchDid()
const {getAgent} = useAgent()
useEffect(() => { useEffect(() => {
let aborted = false let aborted = false
@@ -135,7 +137,7 @@ export function useExternalLinkFetch({
}) })
} }
return cleanup return cleanup
}, [extLink, setQuote, getPost, fetchDid]) }, [extLink, setQuote, getPost, fetchDid, getAgent])
return {extLink, setExtLink} return {extLink, setExtLink}
} }
+13 -11
View File
@@ -1,19 +1,20 @@
import React, {useState} from 'react' import React, {useState} from 'react'
import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native' import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native'
import {ScrollView, TextInput} from './util' import {msg, Trans} from '@lingui/macro'
import {Text} from '../util/text/Text' import {useLingui} from '@lingui/react'
import {Button} from '../util/forms/Button'
import {ErrorMessage} from '../util/error/ErrorMessage' import {useModalControls} from '#/state/modals'
import * as Toast from '../util/Toast' import {useAgent, useSession, useSessionApi} from '#/state/session'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {Trans, msg} from '@lingui/macro' import {colors, s} from 'lib/styles'
import {useLingui} from '@lingui/react' import {isWeb} from 'platform/detection'
import {useModalControls} from '#/state/modals' import {ErrorMessage} from '../util/error/ErrorMessage'
import {useSession, useSessionApi, getAgent} from '#/state/session' import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {ScrollView, TextInput} from './util'
enum Stages { enum Stages {
InputEmail, InputEmail,
@@ -26,6 +27,7 @@ export const snapPoints = ['90%']
export function Component() { export function Component() {
const pal = usePalette('default') const pal = usePalette('default')
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {getAgent} = useAgent()
const {updateCurrentAccount} = useSessionApi() const {updateCurrentAccount} = useSessionApi()
const {_} = useLingui() const {_} = useLingui()
const [stage, setStage] = useState<Stages>(Stages.InputEmail) const [stage, setStage] = useState<Stages>(Stages.InputEmail)

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