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 */
import {describe, beforeAll, it} from '@jest/globals'
import {beforeAll, describe, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
import {createServer, loginAsAlice, openApp, sleep} from '../util'
describe('Composer', () => {
beforeAll(async () => {
@@ -41,7 +42,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText(
'Post with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
@@ -72,7 +72,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText(
'Reply with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
@@ -104,7 +103,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText(
'QP with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
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 {
downloadAndResize,
DownloadAndResizeOpts,
} from '../../src/lib/media/manip'
import ImageResizer from '@bam.tech/react-native-image-resizer'
import RNFetchBlob from 'rn-fetch-blob'
describe('downloadAndResize', () => {
const errorSpy = jest.spyOn(global.console, 'error')
@@ -30,6 +31,7 @@ describe('downloadAndResize', () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image.jpg'),
info: jest.fn().mockReturnValue({status: 200}),
flush: jest.fn(),
})
@@ -84,6 +86,7 @@ describe('downloadAndResize', () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image'),
info: jest.fn().mockReturnValue({status: 200}),
flush: jest.fn(),
})
@@ -118,4 +121,26 @@ describe('downloadAndResize', () => {
{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?
width := 550
width := 600
maxWidthParam := c.QueryParam("maxwidth")
if maxWidthParam != "" {
maxWidthInt, err := strconv.Atoi(maxWidthParam)
if err != nil || maxWidthInt < 220 || maxWidthInt > 550 {
return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer between 220 and 550)")
if err != nil {
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
+2 -2
View File
@@ -16,8 +16,8 @@ import {useQueryClient} from '@tanstack/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {init as initPersistedState} from '#/state/persisted'
import * as persisted from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {useNotificationsListener} from 'lib/notifications/notifications'
import {QueryProvider} from 'lib/react-query'
@@ -64,7 +64,7 @@ function InnerApp() {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
const account = persisted.get('session').currentAccount
const account = readLastActiveAccount()
resumeSession(account)
}, [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 {init as initPersistedState} from '#/state/persisted'
import * as persisted from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query'
import {ThemeProvider} from 'lib/ThemeContext'
@@ -42,7 +42,7 @@ function InnerApp() {
// init
useEffect(() => {
const account = persisted.get('session').currentAccount
const account = readLastActiveAccount()
resumeSession(account)
}, [resumeSession])
+3 -1
View File
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation'
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 * as Toast from '#/view/com/util/Toast'
import {atoms as a, native, useTheme} from '#/alf'
@@ -35,6 +35,7 @@ export function SubmitView({
}) {
const t = useTheme()
const {_} = useLingui()
const {getAgent} = useAgent()
const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>([
@@ -90,6 +91,7 @@ export function SubmitView({
selectedServices,
onSubmitComplete,
setError,
getAgent,
])
return (
+3 -2
View File
@@ -1,12 +1,13 @@
import React from 'react'
import {RichText as RichTextAPI} from '@atproto/api'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
export function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = React.useState(text)
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
const {getAgent} = useAgent()
if (text !== prevText) {
setPrevText(text)
setRawRT(new RichTextAPI({text}))
@@ -27,7 +28,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
return () => {
ignore = true
}
}, [text])
}, [text, getAgent])
const isResolving = resolvedRT === null
return [resolvedRT ?? rawRT, isResolving]
}
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links'
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 {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -173,6 +173,7 @@ function AppealForm({
const {gtMobile} = useBreakpoints()
const [details, setDetails] = React.useState('')
const isAccountReport = 'did' in subject
const {getAgent} = useAgent()
const onSubmit = async () => {
try {
+17 -4
View File
@@ -1,15 +1,28 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
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> {
const res = await getAgent().getAuthorFeed({
const res = await this.getAgent().getAuthorFeed({
...this.params,
limit: 1,
})
@@ -23,7 +36,7 @@ export class AuthorFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().getAuthorFeed({
const res = await this.getAgent().getAuthorFeed({
...this.params,
cursor,
limit,
+22 -6
View File
@@ -2,18 +2,30 @@ import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
AtpAgent,
BskyAgent,
} from '@atproto/api'
import {getContentLanguages} from '#/state/preferences/languages'
import {getAgent} from '#/state/session'
import {FeedAPI, FeedAPIResponse} from './types'
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> {
const contentLangs = getContentLanguages().join(',')
const res = await getAgent().app.bsky.feed.getFeed(
const res = await this.getAgent().app.bsky.feed.getFeed(
{
...this.params,
limit: 1,
@@ -31,15 +43,19 @@ export class CustomFeedAPI implements FeedAPI {
limit: number
}): Promise<FeedAPIResponse> {
const contentLangs = getContentLanguages().join(',')
const agent = getAgent()
const agent = this.getAgent()
const res = agent.session
? await getAgent().app.bsky.feed.getFeed(
? await this.getAgent().app.bsky.feed.getFeed(
{
...this.params,
cursor,
limit,
},
{headers: {'Accept-Language': contentLangs}},
{
headers: {
'Accept-Language': contentLangs,
},
},
)
: await loggedOutFetch({...this.params, cursor, limit})
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 {getAgent} from '#/state/session'
export class FollowingFeedAPI implements FeedAPI {
constructor() {}
getAgent: () => BskyAgent
constructor({getAgent}: {getAgent: () => BskyAgent}) {
this.getAgent = getAgent
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().getTimeline({
const res = await this.getAgent().getTimeline({
limit: 1,
})
return res.data.feed[0]
@@ -19,7 +23,7 @@ export class FollowingFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().getTimeline({
const res = await this.getAgent().getTimeline({
cursor,
limit,
})
+18 -9
View File
@@ -1,8 +1,9 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {FollowingFeedAPI} from './following'
import {CustomFeedAPI} from './custom'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {CustomFeedAPI} from './custom'
import {FollowingFeedAPI} from './following'
import {FeedAPI, FeedAPIResponse} from './types'
// HACK
// 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 {
getAgent: () => BskyAgent
following: FollowingFeedAPI
discover: CustomFeedAPI
usingDiscover = false
itemCursor = 0
constructor() {
this.following = new FollowingFeedAPI()
this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')})
constructor({getAgent}: {getAgent: () => BskyAgent}) {
this.getAgent = getAgent
this.following = new FollowingFeedAPI({getAgent})
this.discover = new CustomFeedAPI({
getAgent,
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
})
}
reset() {
this.following = new FollowingFeedAPI()
this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')})
this.following = new FollowingFeedAPI({getAgent: this.getAgent})
this.discover = new CustomFeedAPI({
getAgent: this.getAgent,
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
})
this.usingDiscover = false
this.itemCursor = 0
}
+17 -4
View File
@@ -1,15 +1,28 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetActorLikes as GetActorLikes,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
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> {
const res = await getAgent().getActorLikes({
const res = await this.getAgent().getActorLikes({
...this.params,
limit: 1,
})
@@ -23,7 +36,7 @@ export class LikesFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().getActorLikes({
const res = await this.getAgent().getActorLikes({
...this.params,
cursor,
limit,
+17 -4
View File
@@ -1,15 +1,28 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetListFeed as GetListFeed,
BskyAgent,
} from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types'
import {getAgent} from '#/state/session'
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> {
const res = await getAgent().app.bsky.feed.getListFeed({
const res = await this.getAgent().app.bsky.feed.getListFeed({
...this.params,
limit: 1,
})
@@ -23,7 +36,7 @@ export class ListFeedAPI implements FeedAPI {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await getAgent().app.bsky.feed.getListFeed({
const res = await this.getAgent().app.bsky.feed.getListFeed({
...this.params,
cursor,
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 {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 {timeout} from 'lib/async/timeout'
import {feedUriToHref} from 'lib/strings/url-helpers'
import {FeedTuner} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip'
import {getAgent} from '#/state/session'
import {getContentLanguages} from '#/state/preferences/languages'
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
export class MergeFeedAPI implements FeedAPI {
getAgent: () => BskyAgent
params: FeedParams
feedTuners: FeedTunerFn[]
following: MergeFeedSource_Following
customFeeds: MergeFeedSource_Custom[] = []
feedCursor = 0
itemCursor = 0
sampleCursor = 0
constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) {
this.following = new MergeFeedSource_Following(this.feedTuners)
constructor({
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() {
this.following = new MergeFeedSource_Following(this.feedTuners)
this.following = new MergeFeedSource_Following({
getAgent: this.getAgent,
feedTuners: this.feedTuners,
})
this.customFeeds = []
this.feedCursor = 0
this.itemCursor = 0
@@ -33,7 +53,12 @@ export class MergeFeedAPI implements FeedAPI {
if (this.params.mergeFeedSources) {
this.customFeeds = shuffle(
this.params.mergeFeedSources.map(
feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners),
feedUri =>
new MergeFeedSource_Custom({
getAgent: this.getAgent,
feedUri,
feedTuners: this.feedTuners,
}),
),
)
} else {
@@ -42,7 +67,7 @@ export class MergeFeedAPI implements FeedAPI {
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await getAgent().getTimeline({
const res = await this.getAgent().getTimeline({
limit: 1,
})
return res.data.feed[0]
@@ -136,12 +161,23 @@ export class MergeFeedAPI implements FeedAPI {
}
class MergeFeedSource {
getAgent: () => BskyAgent
feedTuners: FeedTunerFn[]
sourceInfo: ReasonFeedSource | undefined
cursor: string | undefined = undefined
queue: AppBskyFeedDefs.FeedViewPost[] = []
hasMore = true
constructor(public feedTuners: FeedTunerFn[]) {}
constructor({
getAgent,
feedTuners,
}: {
getAgent: () => BskyAgent
feedTuners: FeedTunerFn[]
}) {
this.getAgent = getAgent
this.feedTuners = feedTuners
}
get numReady() {
return this.queue.length
@@ -203,7 +239,7 @@ class MergeFeedSource_Following extends MergeFeedSource {
cursor: string | undefined,
limit: number,
): 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
const slices = this.tuner.tune(res.data.feed, {
dryRun: false,
@@ -215,10 +251,25 @@ class MergeFeedSource_Following extends MergeFeedSource {
}
class MergeFeedSource_Custom extends MergeFeedSource {
getAgent: () => BskyAgent
minDate: Date
feedUri: string
constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) {
super(feedTuners)
constructor({
getAgent,
feedUri,
feedTuners,
}: {
getAgent: () => BskyAgent
feedUri: string
feedTuners: FeedTunerFn[]
}) {
super({
getAgent,
feedTuners,
})
this.getAgent = getAgent
this.feedUri = feedUri
this.sourceInfo = {
$type: 'reasonFeedSource',
uri: feedUri,
@@ -233,13 +284,17 @@ class MergeFeedSource_Custom extends MergeFeedSource {
): Promise<AppBskyFeedGetTimeline.Response> {
try {
const contentLangs = getContentLanguages().join(',')
const res = await getAgent().app.bsky.feed.getFeed(
const res = await this.getAgent().app.bsky.feed.getFeed(
{
cursor,
limit,
feed: this.feedUri,
},
{headers: {'Accept-Language': contentLangs}},
{
headers: {
'Accept-Language': contentLangs,
},
},
)
// NOTE
// some custom feeds fail to enforce the pagination limit
+4 -15
View File
@@ -1,4 +1,3 @@
import {deleteAsync} from 'expo-file-system'
import {
AppBskyEmbedExternal,
AppBskyEmbedImages,
@@ -20,6 +19,7 @@ import {shortenLinks} from 'lib/strings/rich-text-manip'
import {isNative, isWeb} from 'platform/detection'
import {ImageModel} from 'state/models/media/image'
import {LinkMeta} from '../link-meta/link-meta'
import {safeDeleteAsync} from '../media/manip'
export interface ExternalEmbedDraft {
uri: string
@@ -119,15 +119,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
const {width, height} = image.compressed || image
logger.debug(`Uploading image`)
const res = await uploadBlob(agent, path, 'image/jpeg')
if (isNative) {
try {
deleteAsync(path)
} catch (e) {
console.error(e)
}
safeDeleteAsync(path)
}
images.push({
image: res.data.blob,
alt: image.altText ?? '',
@@ -182,13 +176,8 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
encoding,
)
thumb = thumbUploadRes.data.blob
try {
if (isNative) {
deleteAsync(opts.extLink.localThumb.path)
}
} catch (e) {
console.error(e)
if (isNative) {
safeDeleteAsync(opts.extLink.localThumb.path)
}
}
}
-1
View File
@@ -1,3 +1,2 @@
export const LOGIN_INCLUDE_DEV_SERVERS = 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'
export const STAGING_SERVICE = 'https://staging.bsky.dev'
export const BSKY_SERVICE = 'https://bsky.social'
export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
export const DEFAULT_SERVICE = BSKY_SERVICE
const HELP_DESK_LANG = 'en-us'
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} from 'react-native-image-crop-picker'
import * as RNFS from 'react-native-fs'
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 {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 {Dimensions} from './types'
export async function compressIfNeeded(
img: Image,
@@ -23,7 +24,10 @@ export async function compressIfNeeded(
mode: 'stretch',
maxSize,
})
const finalImageMovedPath = await moveToPermanentPath(resizedImage.path)
const finalImageMovedPath = await moveToPermanentPath(
resizedImage.path,
'.jpg',
)
const finalImg = {
...resizedImage,
path: finalImageMovedPath,
@@ -63,13 +67,15 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
downloadRes = await downloadResPromise
clearTimeout(to1)
let localUri = downloadRes.path()
if (!localUri.startsWith('file://')) {
localUri = `file://${localUri}`
const status = downloadRes.info().status
if (status !== 200) {
return
}
const localUri = normalizePath(downloadRes.path(), true)
return await doResize(localUri, opts)
} finally {
// TODO Whenever we remove `rn-fetch-blob`, we will need to replace this `flush()` with a `deleteAsync()` -hailey
if (downloadRes) {
downloadRes.flush()
}
@@ -105,7 +111,8 @@ export async function shareImageModal({uri}: {uri: string}) {
UTI: 'image/png',
})
}
RNFS.unlink(imagePath)
safeDeleteAsync(imagePath)
}
export async function saveImageToMediaLibrary({uri}: {uri: string}) {
@@ -122,6 +129,7 @@ export async function saveImageToMediaLibrary({uri}: {uri: string}) {
// save
await MediaLibrary.createAssetAsync(imagePath)
safeDeleteAsync(imagePath)
}
export function getImageDim(path: string): Promise<Dimensions> {
@@ -168,6 +176,8 @@ async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
width: resizeRes.width,
height: resizeRes.height,
}
} else {
safeDeleteAsync(resizeRes.path)
}
}
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.
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 destinationPath = joinPath(
RNFS.TemporaryDirectoryPath,
`${filename}${ext}`,
)
await RNFS.moveFile(path, destinationPath)
// cacheDirectory will not ever be null on native, but it could be on web. This function only ever gets called on
// native so we assert as a string.
const destinationPath = joinPath(cacheDirectory as string, filename + ext)
await copyAsync({
from: normalizePath(path),
to: normalizePath(destinationPath),
})
safeDeleteAsync(path)
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) {
if (a.endsWith('/')) {
if (b.startsWith('/')) {
+4 -1
View File
@@ -1,12 +1,13 @@
import {useEffect} from 'react'
import * as Notifications from 'expo-notifications'
import {BskyAgent} from '@atproto/api'
import {QueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
import {truncateAndInvalidate} from '#/state/queries/util'
import {getAgent, SessionAccount} from '#/state/session'
import {SessionAccount} from '#/state/session'
import {track} from 'lib/analytics/analytics'
import {devicePlatform, isIOS} from 'platform/detection'
import {resetToTab} from '../../Navigation'
@@ -18,6 +19,7 @@ const SERVICE_DID = (serviceUrl?: string) =>
: 'did:web:api.bsky.app'
export async function requestPermissionsAndRegisterToken(
getAgent: () => BskyAgent,
account: SessionAccount,
) {
// request notifications permission once the user has logged in
@@ -49,6 +51,7 @@ export async function requestPermissionsAndRegisterToken(
}
export function registerTokenChangeHandler(
getAgent: () => BskyAgent,
account: SessionAccount,
): () => void {
// listens for new changes to the push token
-1
View File
@@ -8,4 +8,3 @@ export type Gate =
| 'start_session_with_following_v2'
| 'test_gate_1'
| '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 (Platform.OS === 'web') {
id = id.replace('AAAAC', 'AAAP3')
filename = filename.replace('.gif', '.webm')
const isSafari = /^((?!chrome|android).)*safari/i.test(
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 {
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 {getLocales} from 'expo-localization'
import {dedupArray} from 'lib/functions'
export const isIOS = Platform.OS === 'ios'
@@ -18,3 +19,8 @@ export const deviceLocales = dedupArray(
.map?.(locale => locale.languageCode)
.filter(code => typeof code === '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 {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {useOnboardingDispatch} from '#/state/shell'
import {getAgent, isSessionDeactivated, useSessionApi} from '#/state/session'
import {logger} from '#/logger'
import {pluralize} from '#/lib/strings/helpers'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Text, P} from '#/components/Typography'
import {pluralize} from '#/lib/strings/helpers'
import {logger} from '#/logger'
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 {Loader} from '#/components/Loader'
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
@@ -25,6 +25,7 @@ export function Deactivated() {
const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch()
const {logout} = useSessionApi()
const {getAgent} = useAgent()
const [isProcessing, setProcessing] = React.useState(false)
const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>(
@@ -56,7 +57,13 @@ export function Deactivated() {
} finally {
setProcessing(false)
}
}, [setProcessing, setEstimatedTime, setPlaceInQueue, onboardingDispatch])
}, [
setProcessing,
setEstimatedTime,
setPlaceInQueue,
onboardingDispatch,
getAgent,
])
React.useEffect(() => {
checkStatus()
+4 -2
View File
@@ -8,7 +8,7 @@ import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useSetSaveFeedsMutation} from '#/state/queries/preferences'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
DescriptionText,
@@ -38,6 +38,7 @@ export function StepFinished() {
const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = React.useState(false)
const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation()
const {getAgent} = useAgent()
const finishOnboarding = React.useCallback(async () => {
setSaving(true)
@@ -57,6 +58,7 @@ export function StepFinished() {
try {
await Promise.all([
bulkWriteFollows(
getAgent,
suggestedAccountsStepResults.accountDids.concat(BSKY_APP_ACCOUNT_DID),
),
// these must be serial
@@ -80,7 +82,7 @@ export function StepFinished() {
track('OnboardingV2:StepFinished:End')
track('OnboardingV2:Complete')
logEvent('onboarding:finished:nextPressed', {})
}, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track])
}, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track, getAgent])
React.useEffect(() => {
track('OnboardingV2:StepFinished:Start')
@@ -8,7 +8,7 @@ import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
DescriptionText,
@@ -39,6 +39,7 @@ export function StepInterests() {
state.interestsStepResults.selectedInterests.map(i => i),
)
const onboardDispatch = useOnboardingDispatch()
const {getAgent} = useAgent()
const {isLoading, isError, error, data, refetch, isFetching} = useQuery({
queryKey: ['interests'],
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 {getAgent} from '#/state/session'
import {PRIMARY_FEEDS} from './StepAlgoFeeds'
function shuffle(array: any) {
@@ -63,7 +66,10 @@ export function aggregateInterestItems(
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
if (!session) {
@@ -87,10 +93,15 @@ export async function bulkWriteFollows(dids: string[]) {
repo: session.did,
writes: followWrites,
})
await whenFollowsIndexed(session.did, res => !!res.data.follows.length)
await whenFollowsIndexed(
getAgent,
session.did,
res => !!res.data.follows.length,
)
}
async function whenFollowsIndexed(
getAgent: () => BskyAgent,
actor: string,
fn: (res: AppBskyGraphGetFollows.Response) => boolean,
) {
@@ -21,6 +21,7 @@ import {usePreferencesQuery} from '#/state/queries/preferences'
import {useRequireAuth, useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {useHaptics} from 'lib/haptics'
import {isIOS} from 'platform/detection'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
@@ -164,10 +165,12 @@ let ProfileHeaderLabeler = ({
moderation={moderation}
hideBackButton={hideBackButton}
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
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]}
pointerEvents="box-none">
pointerEvents={isIOS ? 'auto' : 'box-none'}>
{isMe ? (
<Button
testID="profileHeaderEditProfileButton"
@@ -12,7 +12,7 @@ import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {isIOS, isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
import {
@@ -152,10 +152,12 @@ let ProfileHeaderStandard = ({
moderation={moderation}
hideBackButton={hideBackButton}
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
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_sm]}
pointerEvents="box-none">
pointerEvents={isIOS ? 'auto' : 'box-none'}>
{isMe ? (
<Button
testID="profileHeaderEditProfileButton"
+6 -3
View File
@@ -12,6 +12,7 @@ import {useSession} from '#/state/session'
import {BACK_HITSLOP} from 'lib/constants'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {isIOS} from 'platform/detection'
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {UserBanner} from 'view/com/util/UserBanner'
@@ -61,8 +62,8 @@ let ProfileHeaderShell = ({
)
return (
<View style={t.atoms.bg} pointerEvents="box-none">
<View pointerEvents="none">
<View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View pointerEvents={isIOS ? 'auto' : 'none'}>
{isPlaceholderProfile ? (
<LoadingPlaceholder
width="100%"
@@ -80,7 +81,9 @@ let ProfileHeaderShell = ({
{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} />
{isMe && (
<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 {createFullHandle} from '#/lib/strings/handles'
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 {
initialState,
@@ -35,6 +35,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
const [state, dispatch] = React.useReducer(reducer, initialState)
const submit = useSubmitSignup({state, dispatch})
const {gtMobile} = useBreakpoints()
const {getAgent} = useAgent()
const {
data: serviceInfo,
@@ -113,6 +114,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
state.serviceDescription?.phoneVerificationRequired,
state.userDomain,
submit,
getAgent,
])
const onBackPress = React.useCallback(() => {
+3 -2
View File
@@ -1,6 +1,6 @@
import {z} from 'zod'
import {deviceLocales} from '#/platform/detection'
import {deviceLocales, prefersReducedMotion} from '#/platform/detection'
const externalEmbedOptions = ['show', 'hide'] as const
@@ -15,6 +15,7 @@ const accountSchema = z.object({
refreshJwt: z.string().optional(), // optional because it can expire
accessJwt: z.string().optional(), // optional because it can expire
deactivated: z.boolean().optional(),
pdsUrl: z.string().optional(),
})
export type PersistedAccount = z.infer<typeof accountSchema>
@@ -98,5 +99,5 @@ export const defaults: Schema = {
lastSelectedHomeFeed: undefined,
pdsAddressHistory: [],
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 {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
const DEFAULT_MOD_OPTS = {
@@ -18,6 +18,7 @@ export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix]
export function useActorAutocompleteQuery(prefix: string) {
const moderationOpts = useModerationOpts()
const {getAgent} = useAgent()
prefix = prefix.toLowerCase()
@@ -46,6 +47,7 @@ export type ActorAutocompleteFn = ReturnType<typeof useActorAutocompleteFn>
export function useActorAutocompleteFn() {
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
const {getAgent} = useAgent()
return React.useCallback(
async ({query, limit = 8}: {query: string; limit?: number}) => {
@@ -74,7 +76,7 @@ export function useActorAutocompleteFn() {
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 {STALE} from '#/state/queries'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'actor-search'
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
@@ -14,6 +14,7 @@ export function useActorSearch({
query: string
enabled?: boolean
}) {
const {getAgent} = useAgent()
return useQuery<AppBskyActorDefs.ProfileView[]>({
staleTime: STALE.MINUTES.ONE,
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 {STALE} from '#/state/queries'
import {getAgent} from '../session'
import {useAgent} from '../session'
const RQKEY_ROOT = 'app-passwords'
export const RQKEY = () => [RQKEY_ROOT]
export function useAppPasswordsQuery() {
const {getAgent} = useAgent()
return useQuery({
staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY(),
@@ -20,6 +21,7 @@ export function useAppPasswordsQuery() {
export function useAppPasswordCreateMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<
ComAtprotoServerCreateAppPassword.OutputSchema,
Error,
@@ -42,6 +44,7 @@ export function useAppPasswordCreateMutation() {
export function useAppPasswordDeleteMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {name: string}>({
mutationFn: async ({name}) => {
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 {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {getAgent, useSession} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
import {router} from '#/routes'
export type FeedSourceFeedInfo = {
@@ -140,6 +140,7 @@ export function getAvatarTypeFromUri(uri: string) {
export function useFeedSourceInfoQuery({uri}: {uri: string}) {
const type = getFeedTypeFromUri(uri)
const {getAgent} = useAgent()
return useQuery({
staleTime: STALE.INFINITY,
@@ -166,6 +167,7 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) {
export const useGetPopularFeedsQueryKey = ['getPopularFeeds']
export function useGetPopularFeedsQuery() {
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema,
Error,
@@ -187,6 +189,7 @@ export function useGetPopularFeedsQuery() {
}
export function useSearchPopularFeedsMutation() {
const {getAgent} = useAgent()
return useMutation({
mutationFn: async (query: string) => {
const res = await getAgent().app.bsky.unspecced.getPopularFeedGenerators({
@@ -238,6 +241,7 @@ const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
export function usePinnedFeedsInfos() {
const {hasSession} = useSession()
const {getAgent} = useAgent()
const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
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 {STALE} from '#/state/queries'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const handleQueryKeyRoot = 'handle'
const fetchHandleQueryKey = (handleOrDid: string) => [
@@ -14,6 +14,7 @@ const fetchDidQueryKey = (handleOrDid: string) => [didQueryKeyRoot, handleOrDid]
export function useFetchHandle() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return React.useCallback(
async (handleOrDid: string) => {
@@ -27,12 +28,13 @@ export function useFetchHandle() {
}
return handleOrDid
},
[queryClient],
[queryClient, getAgent],
)
}
export function useUpdateHandleMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({
mutationFn: async ({handle}: {handle: string}) => {
@@ -48,6 +50,7 @@ export function useUpdateHandleMutation() {
export function useFetchDid() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return React.useCallback(
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 {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
export const PUBLIC_BSKY_AGENT = new BskyAgent({
service: 'https://public.api.bsky.app',
service: PUBLIC_BSKY_SERVICE,
})
export const STALE = {
+2 -1
View File
@@ -3,7 +3,7 @@ import {useQuery} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors'
import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean {
return invite.available - invite.uses.length > 0 && !invite.disabled
@@ -16,6 +16,7 @@ export type InviteCodesQueryResponse = Exclude<
undefined
>
export function useInviteCodesQuery() {
const {getAgent} = useAgent()
return useQuery({
staleTime: STALE.MINUTES.FIVE,
queryKey: [inviteCodesQueryKeyRoot],
+5 -1
View File
@@ -5,7 +5,7 @@ import {z} from 'zod'
import {labelersDetailedInfoQueryKeyRoot} from '#/lib/react-query'
import {STALE} from '#/state/queries'
import {preferencesQueryKey} from '#/state/queries/preferences'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const labelerInfoQueryKeyRoot = 'labeler-info'
export const labelerInfoQueryKey = (did: string) => [
@@ -31,6 +31,7 @@ export function useLabelerInfoQuery({
did?: string
enabled?: boolean
}) {
const {getAgent} = useAgent()
return useQuery({
enabled: !!did && enabled !== false,
queryKey: labelerInfoQueryKey(did as string),
@@ -45,6 +46,7 @@ export function useLabelerInfoQuery({
}
export function useLabelersInfoQuery({dids}: {dids: string[]}) {
const {getAgent} = useAgent()
return useQuery({
enabled: !!dids.length,
queryKey: labelersInfoQueryKey(dids),
@@ -56,6 +58,7 @@ export function useLabelersInfoQuery({dids}: {dids: string[]}) {
}
export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
const {getAgent} = useAgent()
return useQuery({
enabled: !!dids.length,
queryKey: labelersDetailedInfoQueryKey(dids),
@@ -73,6 +76,7 @@ export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
export function useLabelerSubscriptionMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({
async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) {
+3 -1
View File
@@ -1,8 +1,9 @@
import {useMutation} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
export function useLikeMutation() {
const {getAgent} = useAgent()
return useMutation({
mutationFn: async ({uri, cid}: {uri: string; cid: string}) => {
const res = await getAgent().like(uri, cid)
@@ -12,6 +13,7 @@ export function useLikeMutation() {
}
export function useUnlikeMutation() {
const {getAgent} = useAgent()
return useMutation({
mutationFn: async ({uri}: {uri: string}) => {
await getAgent().deleteLike(uri)
+2 -1
View File
@@ -7,7 +7,7 @@ import {
} from '@tanstack/react-query'
import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -16,6 +16,7 @@ const RQKEY_ROOT = 'list-members'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
export function useListMembersQuery(uri: string) {
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyGraphGetList.OutputSchema,
Error,
+4 -1
View File
@@ -19,7 +19,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {STALE} from '#/state/queries'
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
const SANITY_PAGE_LIMIT = 1000
@@ -40,6 +40,7 @@ export interface ListMembersip {
*/
export function useDangerousListMembershipsQuery() {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
return useQuery<ListMembersip[]>({
staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY(),
@@ -91,6 +92,7 @@ export function getMembership(
export function useListMembershipAddMutation() {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient()
return useMutation<
{uri: string; cid: string},
@@ -149,6 +151,7 @@ export function useListMembershipAddMutation() {
export function useListMembershipRemoveMutation() {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient()
return useMutation<
void,
+49 -21
View File
@@ -4,6 +4,7 @@ import {
AppBskyGraphGetList,
AppBskyGraphList,
AtUri,
BskyAgent,
Facet,
} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
@@ -12,7 +13,7 @@ import chunk from 'lodash.chunk'
import {uploadBlob} from '#/lib/api'
import {until} from '#/lib/async/until'
import {STALE} from '#/state/queries'
import {getAgent, useSession} from '../session'
import {useAgent, useSession} from '../session'
import {invalidate as invalidateMyLists} from './my-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 function useListQuery(uri?: string) {
const {getAgent} = useAgent()
return useQuery<AppBskyGraphDefs.ListView, Error>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(uri || ''),
@@ -47,6 +49,7 @@ export interface ListCreateMutateParams {
export function useListCreateMutation() {
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
{
async mutationFn({
@@ -85,9 +88,13 @@ export function useListCreateMutation() {
)
// wait for the appview to update
await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => {
return typeof v?.data?.list.uri === 'string'
})
await whenAppViewReady(
getAgent,
res.uri,
(v: AppBskyGraphGetList.Response) => {
return typeof v?.data?.list.uri === 'string'
},
)
return res
},
onSuccess() {
@@ -109,6 +116,7 @@ export interface ListMetadataMutateParams {
}
export function useListMetadataMutation() {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient()
return useMutation<
{uri: string; cid: string},
@@ -150,12 +158,16 @@ export function useListMetadataMutation() {
).data
// wait for the appview to update
await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => {
const list = v.data.list
return (
list.name === record.name && list.description === record.description
)
})
await whenAppViewReady(
getAgent,
res.uri,
(v: AppBskyGraphGetList.Response) => {
const list = v.data.list
return (
list.name === record.name && list.description === record.description
)
},
)
return res
},
onSuccess(data, variables) {
@@ -172,6 +184,7 @@ export function useListMetadataMutation() {
export function useListDeleteMutation() {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient()
return useMutation<void, Error, {uri: string}>({
mutationFn: async ({uri}) => {
@@ -220,9 +233,13 @@ export function useListDeleteMutation() {
}
// wait for the appview to update
await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => {
return !v?.success
})
await whenAppViewReady(
getAgent,
uri,
(v: AppBskyGraphGetList.Response) => {
return !v?.success
},
)
},
onSuccess() {
invalidateMyLists(queryClient)
@@ -236,6 +253,7 @@ export function useListDeleteMutation() {
export function useListMuteMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {uri: string; mute: boolean}>({
mutationFn: async ({uri, mute}) => {
if (mute) {
@@ -244,9 +262,13 @@ export function useListMuteMutation() {
await getAgent().unmuteModList(uri)
}
await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => {
return Boolean(v?.data.list.viewer?.muted) === mute
})
await whenAppViewReady(
getAgent,
uri,
(v: AppBskyGraphGetList.Response) => {
return Boolean(v?.data.list.viewer?.muted) === mute
},
)
},
onSuccess(data, variables) {
queryClient.invalidateQueries({
@@ -258,6 +280,7 @@ export function useListMuteMutation() {
export function useListBlockMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {uri: string; block: boolean}>({
mutationFn: async ({uri, block}) => {
if (block) {
@@ -266,11 +289,15 @@ export function useListBlockMutation() {
await getAgent().unblockModList(uri)
}
await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => {
return block
? typeof v?.data.list.viewer?.blocked === 'string'
: !v?.data.list.viewer?.blocked
})
await whenAppViewReady(
getAgent,
uri,
(v: AppBskyGraphGetList.Response) => {
return block
? typeof v?.data.list.viewer?.blocked === 'string'
: !v?.data.list.viewer?.blocked
},
)
},
onSuccess(data, variables) {
queryClient.invalidateQueries({
@@ -281,6 +308,7 @@ export function useListBlockMutation() {
}
async function whenAppViewReady(
getAgent: () => BskyAgent,
uri: string,
fn: (res: AppBskyGraphGetList.Response) => boolean,
) {
+2 -1
View File
@@ -6,13 +6,14 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'my-blocked-accounts'
export const RQKEY = () => [RQKEY_ROOT]
type RQPageParam = string | undefined
export function useMyBlockedAccountsQuery() {
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyGraphGetBlocks.OutputSchema,
Error,
+2 -1
View File
@@ -3,7 +3,7 @@ import {QueryClient, useQuery} from '@tanstack/react-query'
import {accumulate} from '#/lib/async/accumulate'
import {STALE} from '#/state/queries'
import {getAgent, useSession} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
export type MyListsFilter =
| 'all'
@@ -16,6 +16,7 @@ export const RQKEY = (filter: MyListsFilter) => [RQKEY_ROOT, filter]
export function useMyListsQuery(filter: MyListsFilter) {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
return useQuery<AppBskyGraphDefs.ListView[]>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(filter),
+2 -1
View File
@@ -6,13 +6,14 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'my-muted-accounts'
export const RQKEY = () => [RQKEY_ROOT]
type RQPageParam = string | undefined
export function useMyMutedAccountsQuery() {
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyGraphGetMutes.OutputSchema,
Error,
+3
View File
@@ -27,6 +27,7 @@ import {
} from '@tanstack/react-query'
import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session'
import {STALE} from '..'
import {useModerationOpts} from '../preferences'
import {embedViewRecordToPostView, getEmbeddedPost} from '../util'
@@ -46,6 +47,7 @@ export function RQKEY() {
}
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const {getAgent} = useAgent()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
@@ -71,6 +73,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
if (!page) {
page = (
await fetchPage({
getAgent,
limit: PAGE_SIZE,
cursor: pageParam,
queryClient,
+4 -2
View File
@@ -12,7 +12,7 @@ import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useMutedThreads} from '#/state/muted-threads'
import {getAgent, useSession} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '../preferences'
import {truncateAndInvalidate} from '../util'
import {RQKEY as RQKEY_NOTIFS} from './feed'
@@ -46,6 +46,7 @@ const apiContext = React.createContext<ApiContext>({
export function Provider({children}: React.PropsWithChildren<{}>) {
const {hasSession} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
@@ -144,6 +145,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// count
const {page, indexedAt: lastIndexed} = await fetchPage({
getAgent,
cursor: undefined,
limit: 40,
queryClient,
@@ -196,7 +198,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
},
}
}, [setNumUnread, queryClient, moderationOpts, threadMutes])
}, [setNumUnread, queryClient, moderationOpts, threadMutes, getAgent])
checkUnreadRef.current = api.checkUnread
return (
+13 -9
View File
@@ -1,18 +1,19 @@
import {
AppBskyNotificationListNotifications,
ModerationOpts,
moderateNotification,
AppBskyEmbedRecord,
AppBskyFeedDefs,
AppBskyFeedLike,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedLike,
AppBskyEmbedRecord,
AppBskyNotificationListNotifications,
BskyAgent,
moderateNotification,
ModerationOpts,
} from '@atproto/api'
import chunk from 'lodash.chunk'
import {QueryClient} from '@tanstack/react-query'
import {getAgent} from '../../session'
import chunk from 'lodash.chunk'
import {precacheProfile} from '../profile'
import {NotificationType, FeedNotification, FeedPage} from './types'
import {FeedNotification, FeedPage, NotificationType} from './types'
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
const MS_1HR = 1e3 * 60 * 60
@@ -22,6 +23,7 @@ const MS_2DAY = MS_1HR * 48
// =
export async function fetchPage({
getAgent,
cursor,
limit,
queryClient,
@@ -29,6 +31,7 @@ export async function fetchPage({
threadMutes,
fetchAdditionalData,
}: {
getAgent: () => BskyAgent
cursor: string | undefined
limit: number
queryClient: QueryClient
@@ -53,7 +56,7 @@ export async function fetchPage({
// we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts
if (fetchAdditionalData) {
const subjects = await fetchSubjects(notifsGrouped)
const subjects = await fetchSubjects(getAgent, notifsGrouped)
for (const notif of notifsGrouped) {
if (notif.subjectUri) {
notif.subject = subjects.get(notif.subjectUri)
@@ -137,6 +140,7 @@ export function groupNotifications(
}
async function fetchSubjects(
getAgent: () => BskyAgent,
groupedNotifs: FeedNotification[],
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>()
+36 -17
View File
@@ -4,6 +4,7 @@ import {
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
BskyAgent,
ModerationDecision,
} from '@atproto/api'
import {
@@ -19,7 +20,7 @@ import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
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 {CustomFeedAPI} from 'lib/api/feed/custom'
import {FollowingFeedAPI} from 'lib/api/feed/following'
@@ -104,6 +105,7 @@ export function usePostFeedQuery(
const queryClient = useQueryClient()
const feedTuners = useFeedTuners(feedDesc)
const moderationOpts = useModerationOpts()
const {getAgent} = useAgent()
const enabled = opts?.enabled !== false && Boolean(moderationOpts)
const lastRun = useRef<{
data: InfiniteData<FeedPageUnselected>
@@ -135,11 +137,15 @@ export function usePostFeedQuery(
queryKey: RQKEY(feedDesc, params),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
logger.debug('usePostFeedQuery', {feedDesc, cursor: pageParam?.cursor})
const {api, cursor} = pageParam
? pageParam
: {
api: createApi(feedDesc, params || {}, feedTuners),
api: createApi({
feedDesc,
feedParams: params || {},
feedTuners,
getAgent,
}),
cursor: undefined,
}
@@ -365,34 +371,47 @@ export async function pollLatest(page: FeedPage | undefined) {
return false
}
function createApi(
feedDesc: FeedDescriptor,
params: FeedParams,
feedTuners: FeedTunerFn[],
) {
function createApi({
feedDesc,
feedParams,
feedTuners,
getAgent,
}: {
feedDesc: FeedDescriptor
feedParams: FeedParams
feedTuners: FeedTunerFn[]
getAgent: () => BskyAgent
}) {
if (feedDesc === 'home') {
if (params.mergeFeedEnabled) {
return new MergeFeedAPI(params, feedTuners)
if (feedParams.mergeFeedEnabled) {
return new MergeFeedAPI({
getAgent,
feedParams,
feedTuners,
})
} else {
return new HomeFeedAPI()
return new HomeFeedAPI({getAgent})
}
} else if (feedDesc === 'following') {
return new FollowingFeedAPI()
return new FollowingFeedAPI({getAgent})
} else if (feedDesc.startsWith('author')) {
const [_, actor, filter] = feedDesc.split('|')
return new AuthorFeedAPI({actor, filter})
return new AuthorFeedAPI({getAgent, feedParams: {actor, filter}})
} else if (feedDesc.startsWith('likes')) {
const [_, actor] = feedDesc.split('|')
return new LikesFeedAPI({actor})
return new LikesFeedAPI({getAgent, feedParams: {actor}})
} else if (feedDesc.startsWith('feedgen')) {
const [_, feed] = feedDesc.split('|')
return new CustomFeedAPI({feed})
return new CustomFeedAPI({
getAgent,
feedParams: {feed},
})
} else if (feedDesc.startsWith('list')) {
const [_, list] = feedDesc.split('|')
return new ListFeedAPI({list})
return new ListFeedAPI({getAgent, feedParams: {list}})
} else {
// shouldnt happen
return new FollowingFeedAPI()
return new FollowingFeedAPI({getAgent})
}
}
+2 -1
View File
@@ -6,7 +6,7 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -16,6 +16,7 @@ const RQKEY_ROOT = 'liked-by'
export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri]
export function useLikedByQuery(resolvedUri: string | undefined) {
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyFeedGetLikes.OutputSchema,
Error,
+2 -1
View File
@@ -6,7 +6,7 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -16,6 +16,7 @@ const RQKEY_ROOT = 'post-reposted-by'
export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri]
export function usePostRepostedByQuery(resolvedUri: string | undefined) {
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyFeedGetRepostedBy.OutputSchema,
Error,
+2 -1
View File
@@ -7,7 +7,7 @@ import {
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
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 findAllPostsInNotifsQueryData} from './notifications/feed'
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
@@ -66,6 +66,7 @@ export type ThreadNode =
export function usePostThreadQuery(uri: string | undefined) {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useQuery<ThreadNode, Error>({
gcTime: 0,
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 {updatePostShadow} from '#/state/cache/post-shadow'
import {Shadow} from '#/state/cache/types'
import {getAgent, useSession} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
import {findProfileQueryData} from './profile'
const RQKEY_ROOT = 'post'
export const RQKEY = (postUri: string) => [RQKEY_ROOT, postUri]
export function usePostQuery(uri: string | undefined) {
const {getAgent} = useAgent()
return useQuery<AppBskyFeedDefs.PostView>({
queryKey: RQKEY(uri || ''),
async queryFn() {
@@ -30,6 +31,7 @@ export function usePostQuery(uri: string | undefined) {
export function useGetPost() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useCallback(
async ({uri}: {uri: string}) => {
return queryClient.fetchQuery({
@@ -56,7 +58,7 @@ export function useGetPost() {
},
})
},
[queryClient],
[queryClient, getAgent],
)
}
@@ -125,6 +127,7 @@ function usePostLikeMutation(
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const postAuthor = post.author
const {getAgent} = useAgent()
return useMutation<
{uri: string}, // responds with the uri of the like
Error,
@@ -162,6 +165,7 @@ function usePostLikeMutation(
function usePostUnlikeMutation(
logContext: LogEvents['post:unlike']['logContext'],
) {
const {getAgent} = useAgent()
return useMutation<void, Error, {postUri: string; likeUri: string}>({
mutationFn: ({likeUri}) => {
logEvent('post:unlike', {logContext})
@@ -234,6 +238,7 @@ export function usePostRepostMutationQueue(
function usePostRepostMutation(
logContext: LogEvents['post:repost']['logContext'],
) {
const {getAgent} = useAgent()
return useMutation<
{uri: string}, // responds with the uri of the repost
Error,
@@ -252,6 +257,7 @@ function usePostRepostMutation(
function usePostUnrepostMutation(
logContext: LogEvents['post:unrepost']['logContext'],
) {
const {getAgent} = useAgent()
return useMutation<void, Error, {postUri: string; repostUri: string}>({
mutationFn: ({repostUri}) => {
logEvent('post:unrepost', {logContext})
@@ -265,6 +271,7 @@ function usePostUnrepostMutation(
export function usePostDeleteMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {uri: string}>({
mutationFn: async ({uri}) => {
await getAgent().deletePost(uri)
+17 -1
View File
@@ -22,7 +22,7 @@ import {
ThreadViewPreferences,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types'
import {getAgent, useSession} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
import {saveLabelers} from '#/state/session/agent-config'
export * from '#/state/queries/preferences/const'
@@ -33,6 +33,7 @@ const preferencesQueryKeyRoot = 'getPreferences'
export const preferencesQueryKey = [preferencesQueryKeyRoot]
export function usePreferencesQuery() {
const {getAgent} = useAgent()
return useQuery({
staleTime: STALE.SECONDS.FIFTEEN,
structuralSharing: true,
@@ -118,6 +119,7 @@ export function useModerationOpts() {
export function useClearPreferencesMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({
mutationFn: async () => {
@@ -131,6 +133,7 @@ export function useClearPreferencesMutation() {
}
export function usePreferencesSetContentLabelMutation() {
const {getAgent} = useAgent()
const queryClient = useQueryClient()
return useMutation<
@@ -150,6 +153,7 @@ export function usePreferencesSetContentLabelMutation() {
export function useSetContentLabelMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({
mutationFn: async ({
@@ -172,6 +176,7 @@ export function useSetContentLabelMutation() {
export function usePreferencesSetAdultContentMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {enabled: boolean}>({
mutationFn: async ({enabled}) => {
@@ -186,6 +191,7 @@ export function usePreferencesSetAdultContentMutation() {
export function usePreferencesSetBirthDateMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {birthDate: Date}>({
mutationFn: async ({birthDate}: {birthDate: Date}) => {
@@ -200,6 +206,7 @@ export function usePreferencesSetBirthDateMutation() {
export function useSetFeedViewPreferencesMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, Partial<BskyFeedViewPreference>>({
mutationFn: async prefs => {
@@ -214,6 +221,7 @@ export function useSetFeedViewPreferencesMutation() {
export function useSetThreadViewPreferencesMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, Partial<ThreadViewPreferences>>({
mutationFn: async prefs => {
@@ -228,6 +236,7 @@ export function useSetThreadViewPreferencesMutation() {
export function useSetSaveFeedsMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<
void,
@@ -246,6 +255,7 @@ export function useSetSaveFeedsMutation() {
export function useSaveFeedMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => {
@@ -261,6 +271,7 @@ export function useSaveFeedMutation() {
export function useRemoveFeedMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => {
@@ -276,6 +287,7 @@ export function useRemoveFeedMutation() {
export function usePinFeedMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => {
@@ -291,6 +303,7 @@ export function usePinFeedMutation() {
export function useUnpinFeedMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, unknown, {uri: string}>({
mutationFn: async ({uri}) => {
@@ -306,6 +319,7 @@ export function useUnpinFeedMutation() {
export function useUpsertMutedWordsMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
@@ -320,6 +334,7 @@ export function useUpsertMutedWordsMutation() {
export function useUpdateMutedWordMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
@@ -334,6 +349,7 @@ export function useUpdateMutedWordMutation() {
export function useRemoveMutedWordMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
+2 -1
View File
@@ -1,7 +1,7 @@
import {AppBskyFeedGetActorFeeds} from '@atproto/api'
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -15,6 +15,7 @@ export function useProfileFeedgensQuery(
opts?: {enabled?: boolean},
) {
const enabled = opts?.enabled !== false
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyFeedGetActorFeeds.OutputSchema,
Error,
+2 -1
View File
@@ -6,7 +6,7 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -15,6 +15,7 @@ const RQKEY_ROOT = 'profile-followers'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileFollowersQuery(did: string | undefined) {
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyGraphGetFollowers.OutputSchema,
Error,
+2 -1
View File
@@ -7,7 +7,7 @@ import {
} from '@tanstack/react-query'
import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -17,6 +17,7 @@ const RQKEY_ROOT = 'profile-follows'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileFollowsQuery(did: string | undefined) {
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyGraphGetFollows.OutputSchema,
Error,
+2 -1
View File
@@ -1,7 +1,7 @@
import {AppBskyGraphGetLists} from '@atproto/api'
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -11,6 +11,7 @@ export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
const enabled = opts?.enabled !== false
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyGraphGetLists.OutputSchema,
Error,
+15 -2
View File
@@ -8,6 +8,7 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AtUri,
BskyAgent,
} from '@atproto/api'
import {
QueryClient,
@@ -25,7 +26,7 @@ import {Shadow} from '#/state/cache/types'
import {STALE} from '#/state/queries'
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
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_MUTED} from './my-muted-accounts'
import {ThreadNode} from './post-thread'
@@ -53,6 +54,7 @@ export function useProfileQuery({
staleTime?: number
}) {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useQuery<AppBskyActorDefs.ProfileViewDetailed>({
// WARNING
// this staleTime is load-bearing
@@ -77,6 +79,7 @@ export function useProfileQuery({
}
export function useProfilesQuery({handles}: {handles: string[]}) {
const {getAgent} = useAgent()
return useQuery({
staleTime: STALE.MINUTES.FIVE,
queryKey: profilesQueryKey(handles),
@@ -88,6 +91,7 @@ export function useProfilesQuery({handles}: {handles: string[]}) {
}
export function usePrefetchProfileQuery() {
const {getAgent} = useAgent()
const queryClient = useQueryClient()
const prefetchProfileQuery = useCallback(
async (did: string) => {
@@ -99,7 +103,7 @@ export function usePrefetchProfileQuery() {
},
})
},
[queryClient],
[queryClient, getAgent],
)
return prefetchProfileQuery
}
@@ -115,6 +119,7 @@ interface ProfileUpdateParams {
}
export function useProfileUpdateMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, ProfileUpdateParams>({
mutationFn: async ({
profile,
@@ -154,6 +159,7 @@ export function useProfileUpdateMutation() {
return existing
})
await whenAppViewReady(
getAgent,
profile.did,
checkCommitted ||
(res => {
@@ -255,6 +261,7 @@ function useProfileFollowMutation(
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
) {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient()
return useMutation<{uri: string; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => {
@@ -281,6 +288,7 @@ function useProfileFollowMutation(
function useProfileUnfollowMutation(
logContext: LogEvents['profile:unfollow']['logContext'],
) {
const {getAgent} = useAgent()
return useMutation<void, Error, {did: string; followUri: string}>({
mutationFn: async ({followUri}) => {
logEvent('profile:unfollow', {logContext})
@@ -341,6 +349,7 @@ export function useProfileMuteMutationQueue(
function useProfileMuteMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => {
await getAgent().mute(did)
@@ -353,6 +362,7 @@ function useProfileMuteMutation() {
function useProfileUnmuteMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => {
await getAgent().unmute(did)
@@ -419,6 +429,7 @@ export function useProfileBlockMutationQueue(
function useProfileBlockMutation() {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient()
return useMutation<{uri: string; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => {
@@ -439,6 +450,7 @@ function useProfileBlockMutation() {
function useProfileUnblockMutation() {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
const queryClient = useQueryClient()
return useMutation<void, Error, {did: string; blockUri: string}>({
mutationFn: async ({blockUri}) => {
@@ -516,6 +528,7 @@ export function precacheThreadPostProfiles(
}
async function whenAppViewReady(
getAgent: () => BskyAgent,
actor: string,
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 {STALE} from '#/state/queries'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
import {profileBasicQueryKey as RQKEY_PROFILE_BASIC} from './profile'
const RQKEY_ROOT = 'resolved-did'
@@ -24,6 +24,7 @@ export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult {
export function useResolveDidQuery(didOrHandle: string | undefined) {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useQuery<string, Error>({
staleTime: STALE.HOURS.ONE,
+2 -1
View File
@@ -6,7 +6,7 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
const searchPostsQueryKeyRoot = 'search-posts'
@@ -25,6 +25,7 @@ export function useSearchPostsQuery({
sort?: 'top' | 'latest'
enabled?: boolean
}) {
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyFeedSearchPosts.OutputSchema,
Error,
+2 -1
View File
@@ -2,12 +2,13 @@ import {AppBskyFeedGetSuggestedFeeds} from '@atproto/api'
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
import {STALE} from '#/state/queries'
import {getAgent} from '#/state/session'
import {useAgent} from '#/state/session'
const suggestedFeedsQueryKeyRoot = 'suggestedFeeds'
export const suggestedFeedsQueryKey = [suggestedFeedsQueryKeyRoot]
export function useSuggestedFeedsQuery() {
const {getAgent} = useAgent()
return useInfiniteQuery<
AppBskyFeedGetSuggestedFeeds.OutputSchema,
Error,
+3 -26
View File
@@ -1,4 +1,3 @@
import React from 'react'
import {
AppBskyActorDefs,
AppBskyActorGetSuggestions,
@@ -11,12 +10,11 @@ import {
QueryKey,
useInfiniteQuery,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import {STALE} from '#/state/queries'
import {useModerationOpts} from '#/state/queries/preferences'
import {getAgent, useSession} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
const suggestedFollowsQueryKeyRoot = 'suggested-follows'
const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot]
@@ -29,6 +27,7 @@ const suggestedFollowsByActorQueryKey = (did: string) => [
export function useSuggestedFollowsQuery() {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
const moderationOpts = useModerationOpts()
return useInfiniteQuery<
@@ -79,6 +78,7 @@ export function useSuggestedFollowsQuery() {
}
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
const {getAgent} = useAgent()
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
queryKey: suggestedFollowsByActorQueryKey(did),
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(
queryClient: QueryClient,
did: string,
+25 -11
View File
@@ -23,16 +23,14 @@ import {readLabelers} from './agent-config'
let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
/**
* 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() {
function __getAgent() {
return __globalAgent
}
export function useAgent() {
return React.useMemo(() => ({getAgent: __getAgent}), [])
}
export type SessionAccount = persisted.PersistedAccount
export type SessionState = {
@@ -117,6 +115,7 @@ const ApiContext = React.createContext<ApiContext>({
})
function createPersistSessionHandler(
agent: BskyAgent,
account: SessionAccount,
persistSessionCallback: (props: {
expired: boolean
@@ -144,6 +143,7 @@ function createPersistSessionHandler(
email: session?.email || account.email,
emailConfirmed: session?.emailConfirmed || account.emailConfirmed,
deactivated: isSessionDeactivated(session?.accessJwt),
pdsUrl: agent.pdsUrl?.toString(),
/*
* 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,
accessJwt: agent.session.accessJwt,
deactivated,
pdsUrl: agent.pdsUrl?.toString(),
}
await configureModeration(agent, account)
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
@@ -327,12 +329,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt),
pdsUrl: agent.pdsUrl?.toString(),
}
await configureModeration(agent, account)
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
@@ -379,16 +383,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logger.debug(`session: initSession`, {}, logger.DebugContext.session)
const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency')
const agent = new BskyAgent({
service: account.service,
persistSession: createPersistSessionHandler(
const agent = new BskyAgent({service: account.service})
// 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,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
})
)
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
await configureModeration(agent, account)
@@ -421,6 +433,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logger.debug(`session: attempting to reuse previous session`)
agent.session = prevSession
__globalAgent = agent
await fetchingGates
upsertAccount(account)
@@ -498,6 +511,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
refreshJwt: agent.session.refreshJwt,
accessJwt: 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 {Gif} from '#/state/queries/tenor'
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 {useAnalytics} from 'lib/analytics/analytics'
import * as apilib from 'lib/api/index'
@@ -53,7 +53,7 @@ import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
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 * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
@@ -83,6 +83,7 @@ export const ComposePost = observer(function ComposePost({
imageUris: initImageUris,
}: Props) {
const {currentAccount} = useSession()
const {getAgent} = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
const {isModalActive} = useModals()
const {closeComposer} = useComposerControls()
@@ -483,8 +484,13 @@ export const ComposePost = observer(function ComposePost({
/>
)}
{quote ? (
<View style={[s.mt5, isWeb && s.mb10, {pointerEvents: 'none'}]}>
<QuoteEmbed quote={quote} />
<View style={[s.mt5, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : undefined}
</ScrollView>
+16 -20
View File
@@ -28,8 +28,8 @@ import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
import {useTheme} from 'lib/ThemeContext'
import {isIOS} from 'platform/detection'
import {
addLinkCardIfNecessary,
findIndexInText,
LinkFacetMatch,
suggestLinkCardUri,
} from 'view/com/composer/text-input/text-input-util'
import {Text} from 'view/com/util/text/Text'
import {Autocomplete} from './mobile/Autocomplete'
@@ -73,7 +73,6 @@ export const TextInput = forwardRef(function TextInputImpl(
const theme = useTheme()
const [autocompletePrefix, setAutocompletePrefix] = useState('')
const prevLength = React.useRef(richtext.length)
const prevAddedLinks = useRef(new Set<string>())
React.useImperativeHandle(ref, () => ({
focus: () => textInput.current?.focus(),
@@ -83,6 +82,8 @@ export const TextInput = forwardRef(function TextInputImpl(
getCursorPosition: () => undefined, // Not implemented on native
}))
const pastSuggestedUris = useRef(new Set<string>())
const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
const onChangeText = useCallback(
(newText: string) => {
/*
@@ -112,6 +113,7 @@ export const TextInput = forwardRef(function TextInputImpl(
setAutocompletePrefix('')
}
const nextDetectedUris = new Map<string, LinkFacetMatch>()
if (newRt.facets) {
for (const facet of newRt.facets) {
for (const feature of facet.features) {
@@ -130,32 +132,26 @@ export const TextInput = forwardRef(function TextInputImpl(
onPhotoPasted(res.path)
}
} else {
const cursorLocation = textInputSelection.current.end
addLinkCardIfNecessary({
uri: feature.uri,
newText,
cursorLocation,
mayBePaste,
onNewLink,
prevAddedLinks: prevAddedLinks.current,
})
nextDetectedUris.set(feature.uri, {facet, rt: newRt})
}
}
}
}
}
for (const uri of prevAddedLinks.current.keys()) {
if (findIndexInText(uri, newText) === -1) {
prevAddedLinks.current.delete(uri)
}
const suggestedUri = suggestLinkCardUri(
mayBePaste,
nextDetectedUris,
prevDetectedUris.current,
pastSuggestedUris.current,
)
prevDetectedUris.current = nextDetectedUris
if (suggestedUri) {
onNewLink(suggestedUri)
}
prevLength.current = newText.length
}, 1)
},
[setRichText, autocompletePrefix, onPhotoPasted, prevAddedLinks, onNewLink],
[setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
)
const onPaste = useCallback(
@@ -19,8 +19,8 @@ import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {blobToDataUri, isUriImage} from 'lib/media/util'
import {
addLinkCardIfNecessary,
findIndexInText,
LinkFacetMatch,
suggestLinkCardUri,
} from 'view/com/composer/text-input/text-input-util'
import {Portal} from '#/components/Portal'
import {Text} from '../../util/text/Text'
@@ -61,9 +61,6 @@ export const TextInput = React.forwardRef(function TextInputImpl(
ref,
) {
const autocomplete = useActorAutocompleteFn()
const prevLength = React.useRef(0)
const prevAddedLinks = useRef(new Set<string>())
const pal = usePalette('default')
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
@@ -144,6 +141,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
}
}, [setIsDropping])
const pastSuggestedUris = useRef(new Set<string>())
const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
const editor = useEditor(
{
extensions,
@@ -185,42 +184,34 @@ export const TextInput = React.forwardRef(function TextInputImpl(
},
onUpdate({editor: editorProp}) {
const json = editorProp.getJSON()
const newText = editorJsonToText(json).trimEnd()
const mayBePaste = newText.length > prevLength.current + 1
const newText = editorJsonToText(json)
const isPaste = window.event?.type === 'paste'
const newRt = new RichText({text: newText})
newRt.detectFacetsWithoutResolution()
setRichText(newRt)
const nextDetectedUris = new Map<string, LinkFacetMatch>()
if (newRt.facets) {
for (const facet of newRt.facets) {
for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) {
// The TipTap editor shows the position as being one character ahead, as if the start index is 1.
// 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,
})
nextDetectedUris.set(feature.uri, {facet, rt: newRt})
}
}
}
}
for (const uri of prevAddedLinks.current.keys()) {
if (findIndexInText(uri, newText) === -1) {
prevAddedLinks.current.delete(uri)
}
const suggestedUri = suggestLinkCardUri(
isPaste,
nextDetectedUris,
prevDetectedUris.current,
pastSuggestedUris.current,
)
prevDetectedUris.current = nextDetectedUris
if (suggestedUri) {
onNewLink(suggestedUri)
}
prevLength.current = newText.length
},
},
[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 = ''
if (json.type === 'doc' || json.type === 'paragraph') {
if (json.type === 'doc') {
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 += '\n'
if (!isLastDocumentChild) {
text += '\n'
}
} else if (json.type === 'hardBreak') {
text += '\n'
} else if (json.type === 'text') {
@@ -1,41 +1,85 @@
export function addLinkCardIfNecessary({
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
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
// If the text being added is not from a paste, then we should only check if the cursor is one
// position ahead of the last character. However, if it is a paste we need to check both if it's
// the same position _or_ one position ahead. That is because iOS will add a space after a paste if
// pasting into the middle of a sentence!
const cursorLocationIsOkay =
cursorLocation === lastCharacterPosition + 1 || mayBePaste
export type LinkFacetMatch = {
rt: RichText
facet: AppBskyRichtextFacet.Main
}
// Checking previouslyAddedLinks keeps a card from getting added over and over i.e.
// Link card added -> Remove link card -> Press back space -> Press space -> Link card added -> and so on
// We use the isValidUrl regex below because we don't want to add embeds only if the url is valid, i.e.
// http://facebook is a valid url, but that doesn't mean we want to embed it. We should only embed if
// the url is a valid url _and_ domain. new URL() won't work for this check.
const shouldCheck =
cursorLocationIsOkay && !prevAddedLinks.has(uri) && isValidUrlAndDomain(uri)
if (shouldCheck) {
onNewLink(uri)
prevAddedLinks.add(uri)
export function suggestLinkCardUri(
mayBePaste: boolean,
nextDetectedUris: Map<string, LinkFacetMatch>,
prevDetectedUris: Map<string, LinkFacetMatch>,
pastSuggestedUris: Set<string>,
): string | undefined {
const suggestedUris = new Set<string>()
for (const [uri, nextMatch] of nextDetectedUris) {
if (!isValidUrlAndDomain(uri)) {
continue
}
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
@@ -46,14 +90,3 @@ function isValidUrlAndDomain(value: string) {
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.
*/
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 {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'
@@ -91,7 +91,7 @@ function iterateUris(str: string, cb: (from: number, to: number) => void) {
uri = `https://${uri}`
}
let from = str.indexOf(match[2], match.index)
let to = from + match[2].length + 1
let to = from + match[2].length
// strip ending puncuation
if (/[.,;!?]$/.test(uri)) {
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 {getLinkMeta} from 'lib/link-meta/link-meta'
import {ComposerOpts} from 'state/shell/composer'
import {getAgent} from '#/state/session'
export function useExternalLinkFetch({}: {
setQuote: (opts: ComposerOpts['quote']) => void
}) {
const {getAgent} = useAgent()
const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
undefined,
)
@@ -39,7 +41,7 @@ export function useExternalLinkFetch({}: {
})
}
return cleanup
}, [extLink])
}, [extLink, getAgent])
return {extLink, setExtLink}
}
+13 -11
View File
@@ -1,24 +1,25 @@
import {useState, useEffect} from 'react'
import {ImageModel} from 'state/models/media/image'
import {useEffect, useState} from 'react'
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 {getLinkMeta} from 'lib/link-meta/link-meta'
import {POST_IMG_MAX} from 'lib/constants'
import {
getPostAsQuote,
getFeedAsEmbed,
getListAsEmbed,
getPostAsQuote,
} from 'lib/link-meta/bsky'
import {getLinkMeta} from 'lib/link-meta/link-meta'
import {downloadAndResize} from 'lib/media/manip'
import {
isBskyPostUrl,
isBskyCustomFeedUrl,
isBskyListUrl,
isBskyPostUrl,
} from 'lib/strings/url-helpers'
import {ImageModel} from 'state/models/media/image'
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({
setQuote,
@@ -30,6 +31,7 @@ export function useExternalLinkFetch({
)
const getPost = useGetPost()
const fetchDid = useFetchDid()
const {getAgent} = useAgent()
useEffect(() => {
let aborted = false
@@ -135,7 +137,7 @@ export function useExternalLinkFetch({
})
}
return cleanup
}, [extLink, setQuote, getPost, fetchDid])
}, [extLink, setQuote, getPost, fetchDid, getAgent])
return {extLink, setExtLink}
}
+13 -11
View File
@@ -1,19 +1,20 @@
import React, {useState} from 'react'
import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native'
import {ScrollView, TextInput} from './util'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {ErrorMessage} from '../util/error/ErrorMessage'
import * as Toast from '../util/Toast'
import {s, colors} from 'lib/styles'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useAgent, useSession, useSessionApi} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useSession, useSessionApi, getAgent} from '#/state/session'
import {colors, s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {ErrorMessage} from '../util/error/ErrorMessage'
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 {
InputEmail,
@@ -26,6 +27,7 @@ export const snapPoints = ['90%']
export function Component() {
const pal = usePalette('default')
const {currentAccount} = useSession()
const {getAgent} = useAgent()
const {updateCurrentAccount} = useSessionApi()
const {_} = useLingui()
const [stage, setStage] = useState<Stages>(Stages.InputEmail)

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