diff options
Diffstat (limited to 'src')
106 files changed, 1500 insertions, 1617 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index ebe6a7cd6..0adbae606 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -29,7 +29,6 @@ const App = observer(() => { analytics.init(store) notifee.init(store) SplashScreen.hide() - store.hackCheckIfUpgradeNeeded() Linking.getInitialURL().then((url: string | null) => { if (url) { handleLink(url) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 2bfc84ea9..a1dbc4af1 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -31,7 +31,7 @@ import {ProfileScreen} from './view/screens/Profile' import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowsScreen} from './view/screens/ProfileFollows' import {PostThreadScreen} from './view/screens/PostThread' -import {PostUpvotedByScreen} from './view/screens/PostUpvotedBy' +import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostRepostedByScreen} from './view/screens/PostRepostedBy' import {DebugScreen} from './view/screens/Debug' import {LogScreen} from './view/screens/Log' @@ -62,7 +62,7 @@ function commonScreens(Stack: typeof HomeTab) { /> <Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} /> <Stack.Screen name="PostThread" component={PostThreadScreen} /> - <Stack.Screen name="PostUpvotedBy" component={PostUpvotedByScreen} /> + <Stack.Screen name="PostLikedBy" component={PostLikedByScreen} /> <Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} /> <Stack.Screen name="Debug" component={DebugScreen} /> <Stack.Screen name="Log" component={LogScreen} /> diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts index b7be6913a..7c38625a2 100644 --- a/src/lib/api/api-polyfill.ts +++ b/src/lib/api/api-polyfill.ts @@ -1,11 +1,11 @@ -import AtpAgent from '@atproto/api' +import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api' import RNFS from 'react-native-fs' const GET_TIMEOUT = 15e3 // 15s const POST_TIMEOUT = 60e3 // 60s export function doPolyfill() { - AtpAgent.configure({fetch: fetchHandler}) + BskyAgent.configure({fetch: fetchHandler}) } interface FetchHandlerResponse { @@ -22,7 +22,7 @@ async function fetchHandler( ): Promise<FetchHandlerResponse> { const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type'] if (reqMimeType && reqMimeType.startsWith('application/json')) { - reqBody = JSON.stringify(reqBody) + reqBody = stringifyLex(reqBody) } else if ( typeof reqBody === 'string' && (reqBody.startsWith('/') || reqBody.startsWith('file:')) @@ -65,7 +65,7 @@ async function fetchHandler( let resBody if (resMimeType) { if (resMimeType.startsWith('application/json')) { - resBody = await res.json() + resBody = jsonToLex(await res.json()) } else if (resMimeType.startsWith('text/')) { resBody = await res.text() } else { diff --git a/src/lib/api/api-polyfill.web.ts b/src/lib/api/api-polyfill.web.ts index 1469cf905..1ad22b3d0 100644 --- a/src/lib/api/api-polyfill.web.ts +++ b/src/lib/api/api-polyfill.web.ts @@ -1,4 +1,3 @@ export function doPolyfill() { - // TODO needed? native fetch may work fine -prf - // AtpApi.xrpc.fetch = fetchHandler + // no polyfill is needed on web } diff --git a/src/lib/api/build-suggested-posts.ts b/src/lib/api/build-suggested-posts.ts index defa45311..b9feefc72 100644 --- a/src/lib/api/build-suggested-posts.ts +++ b/src/lib/api/build-suggested-posts.ts @@ -1,9 +1,9 @@ import {RootStoreModel} from 'state/index' import { - AppBskyFeedFeedViewPost, + AppBskyFeedDefs, AppBskyFeedGetAuthorFeed as GetAuthorFeed, } from '@atproto/api' -type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost +type ReasonRepost = AppBskyFeedDefs.ReasonRepost async function getMultipleAuthorsPosts( rootStore: RootStoreModel, @@ -12,12 +12,12 @@ async function getMultipleAuthorsPosts( limit: number = 10, ) { const responses = await Promise.all( - authors.map((author, index) => - rootStore.api.app.bsky.feed + authors.map((actor, index) => + rootStore.agent .getAuthorFeed({ - author, + actor, limit, - before: cursor ? cursor.split(',')[index] : undefined, + cursor: cursor ? cursor.split(',')[index] : undefined, }) .catch(_err => ({success: false, headers: {}, data: {feed: []}})), ), @@ -29,14 +29,14 @@ function mergePosts( responses: GetAuthorFeed.Response[], {repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean}, ) { - let posts: AppBskyFeedFeedViewPost.Main[] = [] + let posts: AppBskyFeedDefs.FeedViewPost[] = [] if (bestOfOnly) { for (const res of responses) { if (res.success) { - // filter the feed down to the post with the most upvotes + // filter the feed down to the post with the most likes res.data.feed = res.data.feed.reduce( - (acc: AppBskyFeedFeedViewPost.Main[], v) => { + (acc: AppBskyFeedDefs.FeedViewPost[], v) => { if ( !acc?.[0] && !v.reason && @@ -49,7 +49,7 @@ function mergePosts( acc && !v.reason && !v.reply && - v.post.upvoteCount > acc[0]?.post.upvoteCount && + (v.post.likeCount || 0) > (acc[0]?.post.likeCount || 0) && isRecentEnough(v.post.indexedAt) ) { return [v] @@ -92,7 +92,7 @@ function mergePosts( return posts } -function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean { +function isARepostOfSomeoneElse(post: AppBskyFeedDefs.FeedViewPost): boolean { return ( post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' && post.post.author.did !== (post.reason as ReasonRepost).by.did diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index e9a32b7a6..6fdc9a48f 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -1,8 +1,8 @@ -import {AppBskyFeedFeedViewPost} from '@atproto/api' +import {AppBskyFeedDefs} from '@atproto/api' import lande from 'lande' -type FeedViewPost = AppBskyFeedFeedViewPost.Main -import {hasProp} from '@atproto/lexicon' +import {hasProp} from 'lib/type-guards' import {LANGUAGES_MAP_CODE2} from '../../locale/languages' +type FeedViewPost = AppBskyFeedDefs.FeedViewPost export type FeedTunerFn = ( tuner: FeedTuner, @@ -174,7 +174,7 @@ export class FeedTuner { } const item = slices[i].rootItem const isRepost = Boolean(item.reason) - if (!isRepost && item.post.upvoteCount < 2) { + if (!isRepost && (item.post.likeCount || 0) < 2) { slices.splice(i, 1) } } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 85eca4a61..a5aa916df 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,16 +1,16 @@ import { AppBskyEmbedImages, AppBskyEmbedExternal, - ComAtprotoBlobUpload, AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + ComAtprotoRepoUploadBlob, + RichText, } from '@atproto/api' import {AtUri} from '../../third-party/uri' import {RootStoreModel} from 'state/models/root-store' -import {extractEntities} from 'lib/strings/rich-text-detection' import {isNetworkError} from 'lib/strings/errors' import {LinkMeta} from '../link-meta/link-meta' import {Image} from '../media/manip' -import {RichText} from '../strings/rich-text' import {isWeb} from 'platform/detection' export interface ExternalEmbedDraft { @@ -27,7 +27,7 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) { if (didOrHandle.startsWith('did:')) { return didOrHandle } - const res = await store.api.com.atproto.handle.resolve({ + const res = await store.agent.resolveHandle({ handle: didOrHandle, }) return res.data.did @@ -37,15 +37,15 @@ export async function uploadBlob( store: RootStoreModel, blob: string, encoding: string, -): Promise<ComAtprotoBlobUpload.Response> { +): Promise<ComAtprotoRepoUploadBlob.Response> { if (isWeb) { // `blob` should be a data uri - return store.api.com.atproto.blob.upload(convertDataURIToUint8Array(blob), { + return store.agent.uploadBlob(convertDataURIToUint8Array(blob), { encoding, }) } else { // `blob` should be a path to a file in the local FS - return store.api.com.atproto.blob.upload( + return store.agent.uploadBlob( blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts {encoding}, ) @@ -70,22 +70,18 @@ export async function post(store: RootStoreModel, opts: PostOpts) { | AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | AppBskyEmbedRecord.Main + | AppBskyEmbedRecordWithMedia.Main | undefined let reply - const text = new RichText(opts.rawText, undefined, { - cleanNewlines: true, - }).text.trim() + const rt = new RichText( + {text: opts.rawText.trim()}, + { + cleanNewlines: true, + }, + ) opts.onStateChange?.('Processing...') - const entities = extractEntities(text, opts.knownHandles) - if (entities) { - for (const ent of entities) { - if (ent.type === 'mention') { - const prof = await store.profiles.getProfile(ent.value) - ent.value = prof.data.did - } - } - } + await rt.detectFacets(store.agent) if (opts.quote) { embed = { @@ -95,24 +91,37 @@ export async function post(store: RootStoreModel, opts: PostOpts) { cid: opts.quote.cid, }, } as AppBskyEmbedRecord.Main - } else if (opts.images?.length) { - embed = { - $type: 'app.bsky.embed.images', - images: [], - } as AppBskyEmbedImages.Main - let i = 1 + } + + if (opts.images?.length) { + const images: AppBskyEmbedImages.Image[] = [] for (const image of opts.images) { - opts.onStateChange?.(`Uploading image #${i++}...`) + opts.onStateChange?.(`Uploading image #${images.length + 1}...`) const res = await uploadBlob(store, image, 'image/jpeg') - embed.images.push({ - image: { - cid: res.data.cid, - mimeType: 'image/jpeg', - }, + images.push({ + image: res.data.blob, alt: '', // TODO supply alt text }) } - } else if (opts.extLink) { + + if (opts.quote) { + embed = { + $type: 'app.bsky.embed.recordWithMedia', + record: embed, + media: { + $type: 'app.bsky.embed.images', + images, + }, + } as AppBskyEmbedRecordWithMedia.Main + } else { + embed = { + $type: 'app.bsky.embed.images', + images, + } as AppBskyEmbedImages.Main + } + } + + if (opts.extLink && !opts.images?.length) { let thumb if (opts.extLink.localThumb) { opts.onStateChange?.('Uploading link thumbnail...') @@ -138,27 +147,41 @@ export async function post(store: RootStoreModel, opts: PostOpts) { opts.extLink.localThumb.path, encoding, ) - thumb = { - cid: thumbUploadRes.data.cid, - mimeType: encoding, - } + thumb = thumbUploadRes.data.blob } } - embed = { - $type: 'app.bsky.embed.external', - external: { - uri: opts.extLink.uri, - title: opts.extLink.meta?.title || '', - description: opts.extLink.meta?.description || '', - thumb, - }, - } as AppBskyEmbedExternal.Main + + if (opts.quote) { + embed = { + $type: 'app.bsky.embed.recordWithMedia', + record: embed, + media: { + $type: 'app.bsky.embed.external', + external: { + uri: opts.extLink.uri, + title: opts.extLink.meta?.title || '', + description: opts.extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main, + } as AppBskyEmbedRecordWithMedia.Main + } else { + embed = { + $type: 'app.bsky.embed.external', + external: { + uri: opts.extLink.uri, + title: opts.extLink.meta?.title || '', + description: opts.extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main + } } if (opts.replyTo) { const replyToUrip = new AtUri(opts.replyTo) - const parentPost = await store.api.app.bsky.feed.post.get({ - user: replyToUrip.host, + const parentPost = await store.agent.getPost({ + repo: replyToUrip.host, rkey: replyToUrip.rkey, }) if (parentPost) { @@ -175,16 +198,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) { try { opts.onStateChange?.('Posting...') - return await store.api.app.bsky.feed.post.create( - {did: store.me.did || ''}, - { - text, - reply, - embed, - entities, - createdAt: new Date().toISOString(), - }, - ) + return await store.agent.post({ + text: rt.text, + facets: rt.facets, + reply, + embed, + }) } catch (e: any) { console.error(`Failed to create post: ${e.toString()}`) if (isNetworkError(e)) { @@ -197,49 +216,6 @@ export async function post(store: RootStoreModel, opts: PostOpts) { } } -export async function repost(store: RootStoreModel, uri: string, cid: string) { - return await store.api.app.bsky.feed.repost.create( - {did: store.me.did || ''}, - { - subject: {uri, cid}, - createdAt: new Date().toISOString(), - }, - ) -} - -export async function unrepost(store: RootStoreModel, repostUri: string) { - const repostUrip = new AtUri(repostUri) - return await store.api.app.bsky.feed.repost.delete({ - did: repostUrip.hostname, - rkey: repostUrip.rkey, - }) -} - -export async function follow( - store: RootStoreModel, - subjectDid: string, - subjectDeclarationCid: string, -) { - return await store.api.app.bsky.graph.follow.create( - {did: store.me.did || ''}, - { - subject: { - did: subjectDid, - declarationCid: subjectDeclarationCid, - }, - createdAt: new Date().toISOString(), - }, - ) -} - -export async function unfollow(store: RootStoreModel, followUri: string) { - const followUrip = new AtUri(followUri) - return await store.api.app.bsky.graph.follow.delete({ - did: followUrip.hostname, - rkey: followUrip.rkey, - }) -} - // helpers // = diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx new file mode 100644 index 000000000..9f4765ac2 --- /dev/null +++ b/src/lib/media/picker.e2e.tsx @@ -0,0 +1,116 @@ +import {RootStoreModel} from 'state/index' +import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' +import { + scaleDownDimensions, + Dim, + compressIfNeeded, + moveToPremanantPath, +} from 'lib/media/manip' +export type {PickedMedia} from './types' +import RNFS from 'react-native-fs' + +let _imageCounter = 0 +async function getFile() { + const files = await RNFS.readDir( + RNFS.LibraryDirectoryPath.split('/') + .slice(0, -5) + .concat(['Media', 'DCIM', '100APPLE']) + .join('/'), + ) + return files[_imageCounter++ % files.length] +} + +export async function openPicker( + _store: RootStoreModel, + opts: PickerOpts, +): Promise<PickedMedia[]> { + const mediaType = opts.mediaType || 'photo' + const items = await getFile() + const toMedia = (item: RNFS.ReadDirItem) => ({ + mediaType, + path: item.path, + mime: 'image/jpeg', + size: item.size, + width: 4288, + height: 2848, + }) + if (Array.isArray(items)) { + return items.map(toMedia) + } + return [toMedia(items)] +} + +export async function openCamera( + _store: RootStoreModel, + opts: CameraOpts, +): Promise<PickedMedia> { + const mediaType = opts.mediaType || 'photo' + const item = await getFile() + return { + mediaType, + path: item.path, + mime: 'image/jpeg', + size: item.size, + width: 4288, + height: 2848, + } +} + +export async function openCropper( + _store: RootStoreModel, + opts: CropperOpts, +): Promise<PickedMedia> { + const mediaType = opts.mediaType || 'photo' + const item = await getFile() + return { + mediaType, + path: item.path, + mime: 'image/jpeg', + size: item.size, + width: 4288, + height: 2848, + } +} + +export async function pickImagesFlow( + store: RootStoreModel, + maxFiles: number, + maxDim: Dim, + maxSize: number, +) { + const items = await openPicker(store, { + multiple: true, + maxFiles, + mediaType: 'photo', + }) + const result = [] + for (const image of items) { + result.push( + await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), + ) + } + return result +} + +export async function cropAndCompressFlow( + store: RootStoreModel, + path: string, + imgDim: Dim, + maxDim: Dim, + maxSize: number, +) { + // choose target dimensions based on the original + // this causes the photo cropper to start with the full image "selected" + const {width, height} = scaleDownDimensions(imgDim, maxDim) + const cropperRes = await openCropper(store, { + mediaType: 'photo', + path, + freeStyleCropEnabled: true, + width, + height, + }) + + const img = await compressIfNeeded(cropperRes, maxSize) + const permanentPath = await moveToPremanantPath(img.path) + return permanentPath +} diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts index 4baf64050..4b53ed724 100644 --- a/src/lib/notifee.ts +++ b/src/lib/notifee.ts @@ -45,7 +45,7 @@ export function displayNotificationFromModel( let author = notif.author.displayName || notif.author.handle let title: string let body: string = '' - if (notif.isUpvote) { + if (notif.isLike) { title = `${author} liked your post` body = notif.additionalPost?.thread?.postRecord?.text || '' } else if (notif.isRepost) { @@ -65,7 +65,7 @@ export function displayNotificationFromModel( } let image if ( - AppBskyEmbedImages.isPresented(notif.additionalPost?.thread?.post.embed) && + AppBskyEmbedImages.isView(notif.additionalPost?.thread?.post.embed) && notif.additionalPost?.thread?.post.embed.images[0]?.thumb ) { image = notif.additionalPost.thread.post.embed.images[0].thumb diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index cc48e2dbe..59d94efa8 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -10,7 +10,7 @@ export type CommonNavigatorParams = { ProfileFollowers: {name: string} ProfileFollows: {name: string} PostThread: {name: string; rkey: string} - PostUpvotedBy: {name: string; rkey: string} + PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} Debug: undefined Log: undefined diff --git a/src/lib/strings/rich-text-detection.ts b/src/lib/strings/rich-text-detection.ts index 386ed48e1..51d09ec5d 100644 --- a/src/lib/strings/rich-text-detection.ts +++ b/src/lib/strings/rich-text-detection.ts @@ -1,64 +1,5 @@ -import {AppBskyFeedPost} from '@atproto/api' -type Entity = AppBskyFeedPost.Entity import {isValidDomain} from './url-helpers' -export function extractEntities( - text: string, - knownHandles?: Set<string>, -): Entity[] | undefined { - let match - let ents: Entity[] = [] - { - // mentions - const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g - while ((match = re.exec(text))) { - if (knownHandles && !knownHandles.has(match[3])) { - continue // not a known handle - } else if (!match[3].includes('.')) { - continue // probably not a handle - } - const start = text.indexOf(match[3], match.index) - 1 - ents.push({ - type: 'mention', - value: match[3], - index: {start, end: start + match[3].length + 1}, - }) - } - } - { - // links - const re = - /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim - while ((match = re.exec(text))) { - let value = match[2] - if (!value.startsWith('http')) { - const domain = match.groups?.domain - if (!domain || !isValidDomain(domain)) { - continue - } - value = `https://${value}` - } - const start = text.indexOf(match[2], match.index) - const index = {start, end: start + match[2].length} - // strip ending puncuation - if (/[.,;!?]$/.test(value)) { - value = value.slice(0, -1) - index.end-- - } - if (/[)]$/.test(value) && !value.includes('(')) { - value = value.slice(0, -1) - index.end-- - } - ents.push({ - type: 'link', - value, - index, - }) - } - } - return ents.length > 0 ? ents : undefined -} - interface DetectedLink { link: string } diff --git a/src/lib/strings/rich-text-sanitize.ts b/src/lib/strings/rich-text-sanitize.ts deleted file mode 100644 index 0b5895707..000000000 --- a/src/lib/strings/rich-text-sanitize.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {RichText} from './rich-text' - -const EXCESS_SPACE_RE = /[\r\n]([\u00AD\u2060\u200D\u200C\u200B\s]*[\r\n]){2,}/ -const REPLACEMENT_STR = '\n\n' - -export function removeExcessNewlines(richText: RichText): RichText { - return clean(richText, EXCESS_SPACE_RE, REPLACEMENT_STR) -} - -// TODO: check on whether this works correctly with multi-byte codepoints -export function clean( - richText: RichText, - targetRegexp: RegExp, - replacementString: string, -): RichText { - richText = richText.clone() - - let match = richText.text.match(targetRegexp) - while (match && typeof match.index !== 'undefined') { - const oldText = richText.text - const removeStartIndex = match.index - const removeEndIndex = removeStartIndex + match[0].length - richText.delete(removeStartIndex, removeEndIndex) - if (richText.text === oldText) { - break // sanity check - } - richText.insert(removeStartIndex, replacementString) - match = richText.text.match(targetRegexp) - } - - return richText -} diff --git a/src/lib/strings/rich-text.ts b/src/lib/strings/rich-text.ts deleted file mode 100644 index 1df2144e0..000000000 --- a/src/lib/strings/rich-text.ts +++ /dev/null @@ -1,216 +0,0 @@ -/* -= Rich Text Manipulation - -When we sanitize rich text, we have to update the entity indices as the -text is modified. This can be modeled as inserts() and deletes() of the -rich text string. The possible scenarios are outlined below, along with -their expected behaviors. - -NOTE: Slices are start inclusive, end exclusive - -== richTextInsert() - -Target string: - - 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l o w o r l d // string value - ^-------^ // target slice {start: 2, end: 7} - -Scenarios: - -A: ^ // insert "test" at 0 -B: ^ // insert "test" at 4 -C: ^ // insert "test" at 8 - -A = before -> move both by num added -B = inner -> move end by num added -C = after -> noop - -Results: - -A: 0 1 2 3 4 5 6 7 8 910 // string indices - t e s t h e l l o w // string value - ^-------^ // target slice {start: 6, end: 11} - -B: 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l t e s t o w // string value - ^---------------^ // target slice {start: 2, end: 11} - -C: 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l o w o t e s // string value - ^-------^ // target slice {start: 2, end: 7} - -== richTextDelete() - -Target string: - - 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l o w o r l d // string value - ^-------^ // target slice {start: 2, end: 7} - -Scenarios: - -A: ^---------------^ // remove slice {start: 0, end: 9} -B: ^-----^ // remove slice {start: 7, end: 11} -C: ^-----------^ // remove slice {start: 4, end: 11} -D: ^-^ // remove slice {start: 3, end: 5} -E: ^-----^ // remove slice {start: 1, end: 5} -F: ^-^ // remove slice {start: 0, end: 2} - -A = entirely outer -> delete slice -B = entirely after -> noop -C = partially after -> move end to remove-start -D = entirely inner -> move end by num removed -E = partially before -> move start to remove-start index, move end by num removed -F = entirely before -> move both by num removed - -Results: - -A: 0 1 2 3 4 5 6 7 8 910 // string indices - l d // string value - // target slice (deleted) - -B: 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l o w // string value - ^-------^ // target slice {start: 2, end: 7} - -C: 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l // string value - ^-^ // target slice {start: 2, end: 4} - -D: 0 1 2 3 4 5 6 7 8 910 // string indices - h e l w o r l d // string value - ^---^ // target slice {start: 2, end: 5} - -E: 0 1 2 3 4 5 6 7 8 910 // string indices - h w o r l d // string value - ^-^ // target slice {start: 1, end: 3} - -F: 0 1 2 3 4 5 6 7 8 910 // string indices - l l o w o r l d // string value - ^-------^ // target slice {start: 0, end: 5} - */ - -import cloneDeep from 'lodash.clonedeep' -import {AppBskyFeedPost} from '@atproto/api' -import {removeExcessNewlines} from './rich-text-sanitize' - -export type Entity = AppBskyFeedPost.Entity -export interface RichTextOpts { - cleanNewlines?: boolean -} - -export class RichText { - constructor( - public text: string, - public entities?: Entity[], - opts?: RichTextOpts, - ) { - if (opts?.cleanNewlines) { - removeExcessNewlines(this).copyInto(this) - } - } - - clone() { - return new RichText(this.text, cloneDeep(this.entities)) - } - - copyInto(target: RichText) { - target.text = this.text - target.entities = cloneDeep(this.entities) - } - - insert(insertIndex: number, insertText: string) { - this.text = - this.text.slice(0, insertIndex) + - insertText + - this.text.slice(insertIndex) - - if (!this.entities?.length) { - return this - } - - const numCharsAdded = insertText.length - for (const ent of this.entities) { - // see comment at top of file for labels of each scenario - // scenario A (before) - if (insertIndex <= ent.index.start) { - // move both by num added - ent.index.start += numCharsAdded - ent.index.end += numCharsAdded - } - // scenario B (inner) - else if (insertIndex >= ent.index.start && insertIndex < ent.index.end) { - // move end by num added - ent.index.end += numCharsAdded - } - // scenario C (after) - // noop - } - return this - } - - delete(removeStartIndex: number, removeEndIndex: number) { - this.text = - this.text.slice(0, removeStartIndex) + this.text.slice(removeEndIndex) - - if (!this.entities?.length) { - return this - } - - const numCharsRemoved = removeEndIndex - removeStartIndex - for (const ent of this.entities) { - // see comment at top of file for labels of each scenario - // scenario A (entirely outer) - if ( - removeStartIndex <= ent.index.start && - removeEndIndex >= ent.index.end - ) { - // delete slice (will get removed in final pass) - ent.index.start = 0 - ent.index.end = 0 - } - // scenario B (entirely after) - else if (removeStartIndex > ent.index.end) { - // noop - } - // scenario C (partially after) - else if ( - removeStartIndex > ent.index.start && - removeStartIndex <= ent.index.end && - removeEndIndex > ent.index.end - ) { - // move end to remove start - ent.index.end = removeStartIndex - } - // scenario D (entirely inner) - else if ( - removeStartIndex >= ent.index.start && - removeEndIndex <= ent.index.end - ) { - // move end by num removed - ent.index.end -= numCharsRemoved - } - // scenario E (partially before) - else if ( - removeStartIndex < ent.index.start && - removeEndIndex >= ent.index.start && - removeEndIndex <= ent.index.end - ) { - // move start to remove-start index, move end by num removed - ent.index.start = removeStartIndex - ent.index.end -= numCharsRemoved - } - // scenario F (entirely before) - else if (removeEndIndex < ent.index.start) { - // move both by num removed - ent.index.start -= numCharsRemoved - ent.index.end -= numCharsRemoved - } - } - - // filter out any entities that were made irrelevant - this.entities = this.entities.filter(ent => ent.index.start < ent.index.end) - return this - } -} diff --git a/src/lib/styles.ts b/src/lib/styles.ts index aa255b21f..409c77548 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -71,6 +71,7 @@ export const s = StyleSheet.create({ borderBottom1: {borderBottomWidth: 1}, borderLeft1: {borderLeftWidth: 1}, hidden: {display: 'none'}, + dimmed: {opacity: 0.5}, // font weights fw600: {fontWeight: '600'}, diff --git a/src/platform/polyfills.ts b/src/platform/polyfills.ts index 3dbd13981..a64c2c33a 100644 --- a/src/platform/polyfills.ts +++ b/src/platform/polyfills.ts @@ -1,3 +1,5 @@ +import 'fast-text-encoding' +import Graphemer from 'graphemer' export {} /** @@ -48,3 +50,18 @@ globalThis.atob = (str: string): string => { } return result } + +const splitter = new Graphemer() +globalThis.Intl = globalThis.Intl || {} + +// @ts-ignore we're polyfilling -prf +globalThis.Intl.Segmenter = + // @ts-ignore we're polyfilling -prf + globalThis.Intl.Segmenter || + class Segmenter { + constructor() {} + // NOTE + // this is not a precisely correct polyfill but it's sufficient for our needs + // -prf + segment = splitter.iterateGraphemes + } diff --git a/src/platform/polyfills.web.ts b/src/platform/polyfills.web.ts index 7a42f4887..e46963a6f 100644 --- a/src/platform/polyfills.web.ts +++ b/src/platform/polyfills.web.ts @@ -2,3 +2,11 @@ // @ts-ignore whatever typescript wants to complain about here, I dont care about -prf window.setImmediate = (cb: () => void) => setTimeout(cb, 0) + +// @ts-ignore not on the TS signature due to bad support -prf +if (!globalThis.Intl?.Segmenter) { + // NOTE loading as a separate script to reduce main bundle size, as this is only needed in FF -prf + const script = document.createElement('script') + script.setAttribute('src', '/static/js/intl-segmenter-polyfill.min.js') + document.head.appendChild(script) +} diff --git a/src/routes.ts b/src/routes.ts index 6c02a7c50..167efcfb7 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -9,7 +9,7 @@ export const router = new Router({ ProfileFollowers: '/profile/:name/followers', ProfileFollows: '/profile/:name/follows', PostThread: '/profile/:name/post/:rkey', - PostUpvotedBy: '/profile/:name/post/:rkey/upvoted-by', + PostLikedBy: '/profile/:name/post/:rkey/liked-by', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', Debug: '/sys/debug', Log: '/sys/log', diff --git a/src/state/index.ts b/src/state/index.ts index f0713efeb..4755c28f4 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -1,6 +1,6 @@ import {autorun} from 'mobx' import {AppState, Platform} from 'react-native' -import {AtpAgent} from '@atproto/api' +import {BskyAgent} from '@atproto/api' import {RootStoreModel} from './models/root-store' import * as apiPolyfill from 'lib/api/api-polyfill' import * as storage from 'lib/storage' @@ -19,7 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) { apiPolyfill.doPolyfill() - rootStore = new RootStoreModel(new AtpAgent({service: serviceUri})) + rootStore = new RootStoreModel(new BskyAgent({service: serviceUri})) try { data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} rootStore.log.debug('Initial hydrate', {hasSession: !!data.session}) diff --git a/src/state/models/cache/image-sizes.ts b/src/state/models/cache/image-sizes.ts index ff0486278..2fd6e0013 100644 --- a/src/state/models/cache/image-sizes.ts +++ b/src/state/models/cache/image-sizes.ts @@ -3,7 +3,7 @@ import {Dim} from 'lib/media/manip' export class ImageSizesCache { sizes: Map<string, Dim> = new Map() - private activeRequests: Map<string, Promise<Dim>> = new Map() + activeRequests: Map<string, Promise<Dim>> = new Map() constructor() {} diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts index 725b7841e..14eeaae21 100644 --- a/src/state/models/cache/my-follows.ts +++ b/src/state/models/cache/my-follows.ts @@ -1,15 +1,12 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' +import {FollowRecord, AppBskyActorDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' const CACHE_TTL = 1000 * 60 * 60 // hourly type FollowsListResponse = Awaited<ReturnType<FollowRecord['list']>> type FollowsListResponseRecord = FollowsListResponse['records'][0] -type Profile = - | AppBskyActorProfile.ViewBasic - | AppBskyActorProfile.View - | AppBskyActorRef.WithInfo +type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView /** * This model is used to maintain a synced local cache of the user's @@ -53,21 +50,21 @@ export class MyFollowsCache { fetch = bundleAsync(async () => { this.rootStore.log.debug('MyFollowsModel:fetch running full fetch') - let before + let rkeyStart let records: FollowsListResponseRecord[] = [] do { const res: FollowsListResponse = - await this.rootStore.api.app.bsky.graph.follow.list({ - user: this.rootStore.me.did, - before, + await this.rootStore.agent.app.bsky.graph.follow.list({ + repo: this.rootStore.me.did, + rkeyStart, }) records = records.concat(res.records) - before = res.cursor - } while (typeof before !== 'undefined') + rkeyStart = res.cursor + } while (typeof rkeyStart !== 'undefined') runInAction(() => { this.followDidToRecordMap = {} for (const record of records) { - this.followDidToRecordMap[record.value.subject.did] = record.uri + this.followDidToRecordMap[record.value.subject] = record.uri } this.lastSync = Date.now() this.myDid = this.rootStore.me.did diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts index 241338a16..27cee8503 100644 --- a/src/state/models/discovery/foafs.ts +++ b/src/state/models/discovery/foafs.ts @@ -1,15 +1,15 @@ -import {AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import {makeAutoObservable, runInAction} from 'mobx' import sampleSize from 'lodash.samplesize' import {bundleAsync} from 'lib/async/bundle' import {RootStoreModel} from '../root-store' -export type RefWithInfoAndFollowers = AppBskyActorRef.WithInfo & { - followers: AppBskyActorProfile.View[] +export type RefWithInfoAndFollowers = AppBskyActorDefs.ProfileViewBasic & { + followers: AppBskyActorDefs.ProfileView[] } -export type ProfileViewFollows = AppBskyActorProfile.View & { - follows: AppBskyActorRef.WithInfo[] +export type ProfileViewFollows = AppBskyActorDefs.ProfileView & { + follows: AppBskyActorDefs.ProfileViewBasic[] } export class FoafsModel { @@ -51,14 +51,14 @@ export class FoafsModel { this.popular.length = 0 // fetch their profiles - const profiles = await this.rootStore.api.app.bsky.actor.getProfiles({ + const profiles = await this.rootStore.agent.getProfiles({ actors: this.sources, }) // fetch their follows const results = await Promise.allSettled( this.sources.map(source => - this.rootStore.api.app.bsky.graph.getFollows({user: source}), + this.rootStore.agent.getFollows({actor: source}), ), ) diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts index cf8e2dd7b..91c5efd02 100644 --- a/src/state/models/discovery/suggested-actors.ts +++ b/src/state/models/discovery/suggested-actors.ts @@ -1,5 +1,5 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorProfile as Profile} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import shuffle from 'lodash.shuffle' import {RootStoreModel} from '../root-store' import {cleanError} from 'lib/strings/errors' @@ -8,7 +8,9 @@ import {SUGGESTED_FOLLOWS} from 'lib/constants' const PAGE_SIZE = 30 -export type SuggestedActor = Profile.ViewBasic | Profile.View +export type SuggestedActor = + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileView export class SuggestedActorsModel { // state @@ -20,7 +22,7 @@ export class SuggestedActorsModel { hasMore = true loadMoreCursor?: string - private hardCodedSuggestions: SuggestedActor[] | undefined + hardCodedSuggestions: SuggestedActor[] | undefined // data suggestions: SuggestedActor[] = [] @@ -82,7 +84,7 @@ export class SuggestedActorsModel { this.loadMoreCursor = undefined } else { // pull from the PDS' algo - res = await this.rootStore.api.app.bsky.actor.getSuggestions({ + res = await this.rootStore.agent.app.bsky.actor.getSuggestions({ limit: this.pageSize, cursor: this.loadMoreCursor, }) @@ -104,7 +106,7 @@ export class SuggestedActorsModel { } }) - private async fetchHardcodedSuggestions() { + async fetchHardcodedSuggestions() { if (this.hardCodedSuggestions) { return } @@ -118,9 +120,9 @@ export class SuggestedActorsModel { ] // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`) - let profiles: Profile.View[] = [] + let profiles: AppBskyActorDefs.ProfileView[] = [] do { - const res = await this.rootStore.api.app.bsky.actor.getProfiles({ + const res = await this.rootStore.agent.getProfiles({ actors: actors.splice(0, 25), }) profiles = profiles.concat(res.data.profiles) @@ -152,13 +154,13 @@ export class SuggestedActorsModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts index 083863fe2..8b62c958f 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -1,32 +1,29 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetTimeline as GetTimeline, - AppBskyFeedFeedViewPost, + AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedGetAuthorFeed as GetAuthorFeed, + RichText, } from '@atproto/api' import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' import sampleSize from 'lodash.samplesize' -type FeedViewPost = AppBskyFeedFeedViewPost.Main -type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost -type PostView = AppBskyFeedPost.View -import {AtUri} from '../../third-party/uri' import {RootStoreModel} from './root-store' -import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' -import {RichText} from 'lib/strings/rich-text' import {SUGGESTED_FOLLOWS} from 'lib/constants' import { getCombinedCursors, getMultipleAuthorsPosts, mergePosts, } from 'lib/api/build-suggested-posts' - import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' -const PAGE_SIZE = 30 +type FeedViewPost = AppBskyFeedDefs.FeedViewPost +type ReasonRepost = AppBskyFeedDefs.ReasonRepost +type PostView = AppBskyFeedDefs.PostView +const PAGE_SIZE = 30 let _idCounter = 0 export class FeedItemModel { @@ -51,11 +48,7 @@ export class FeedItemModel { const valid = AppBskyFeedPost.validateRecord(this.post.record) if (valid.success) { this.postRecord = this.post.record - this.richText = new RichText( - this.postRecord.text, - this.postRecord.entities, - {cleanNewlines: true}, - ) + this.richText = new RichText(this.postRecord, {cleanNewlines: true}) } else { rootStore.log.warn( 'Received an invalid app.bsky.feed.post record', @@ -82,7 +75,7 @@ export class FeedItemModel { copyMetrics(v: FeedViewPost) { this.post.replyCount = v.post.replyCount this.post.repostCount = v.post.repostCount - this.post.upvoteCount = v.post.upvoteCount + this.post.likeCount = v.post.likeCount this.post.viewer = v.post.viewer } @@ -92,68 +85,43 @@ export class FeedItemModel { } } - async toggleUpvote() { - const wasUpvoted = !!this.post.viewer.upvote - const wasDownvoted = !!this.post.viewer.downvote - const res = await this.rootStore.api.app.bsky.feed.setVote({ - subject: { - uri: this.post.uri, - cid: this.post.cid, - }, - direction: wasUpvoted ? 'none' : 'up', - }) - runInAction(() => { - if (wasDownvoted) { - this.post.downvoteCount-- - } - if (wasUpvoted) { - this.post.upvoteCount-- - } else { - this.post.upvoteCount++ - } - this.post.viewer.upvote = res.data.upvote - this.post.viewer.downvote = res.data.downvote - }) - } - - async toggleDownvote() { - const wasUpvoted = !!this.post.viewer.upvote - const wasDownvoted = !!this.post.viewer.downvote - const res = await this.rootStore.api.app.bsky.feed.setVote({ - subject: { - uri: this.post.uri, - cid: this.post.cid, - }, - direction: wasDownvoted ? 'none' : 'down', - }) - runInAction(() => { - if (wasUpvoted) { - this.post.upvoteCount-- - } - if (wasDownvoted) { - this.post.downvoteCount-- - } else { - this.post.downvoteCount++ - } - this.post.viewer.upvote = res.data.upvote - this.post.viewer.downvote = res.data.downvote - }) + async toggleLike() { + if (this.post.viewer?.like) { + await this.rootStore.agent.deleteLike(this.post.viewer.like) + runInAction(() => { + this.post.likeCount = this.post.likeCount || 0 + this.post.viewer = this.post.viewer || {} + this.post.likeCount-- + this.post.viewer.like = undefined + }) + } else { + const res = await this.rootStore.agent.like(this.post.uri, this.post.cid) + runInAction(() => { + this.post.likeCount = this.post.likeCount || 0 + this.post.viewer = this.post.viewer || {} + this.post.likeCount++ + this.post.viewer.like = res.uri + }) + } } async toggleRepost() { - if (this.post.viewer.repost) { - await apilib.unrepost(this.rootStore, this.post.viewer.repost) + if (this.post.viewer?.repost) { + await this.rootStore.agent.deleteRepost(this.post.viewer.repost) runInAction(() => { + this.post.repostCount = this.post.repostCount || 0 + this.post.viewer = this.post.viewer || {} this.post.repostCount-- this.post.viewer.repost = undefined }) } else { - const res = await apilib.repost( - this.rootStore, + const res = await this.rootStore.agent.repost( this.post.uri, this.post.cid, ) runInAction(() => { + this.post.repostCount = this.post.repostCount || 0 + this.post.viewer = this.post.viewer || {} this.post.repostCount++ this.post.viewer.repost = res.uri }) @@ -161,10 +129,7 @@ export class FeedItemModel { } async delete() { - await this.rootStore.api.app.bsky.feed.post.delete({ - did: this.post.author.did, - rkey: new AtUri(this.post.uri).rkey, - }) + await this.rootStore.agent.deletePost(this.post.uri) this.rootStore.emitPostDeleted(this.post.uri) } } @@ -250,7 +215,7 @@ export class FeedModel { tuner = new FeedTuner() // used to linearize async modifications to state - private lock = new AwaitLock() + lock = new AwaitLock() // data slices: FeedSliceModel[] = [] @@ -291,8 +256,8 @@ export class FeedModel { const params = this.params as GetAuthorFeed.QueryParams const item = slice.rootItem const isRepost = - item?.reasonRepost?.by?.handle === params.author || - item?.reasonRepost?.by?.did === params.author + item?.reasonRepost?.by?.handle === params.actor || + item?.reasonRepost?.by?.did === params.actor return ( !item.reply || // not a reply isRepost || // but allow if it's a repost @@ -338,7 +303,7 @@ export class FeedModel { return this.setup() } - private get feedTuners() { + get feedTuners() { if (this.feedType === 'goodstuff') { return [ FeedTuner.dedupReposts, @@ -406,7 +371,7 @@ export class FeedModel { this._xLoading() try { const res = await this._getFeed({ - before: this.loadMoreCursor, + cursor: this.loadMoreCursor, limit: PAGE_SIZE, }) await this._appendAll(res) @@ -439,7 +404,7 @@ export class FeedModel { try { do { const res: GetTimeline.Response = await this._getFeed({ - before: cursor, + cursor, limit: Math.min(numToFetch, 100), }) if (res.data.feed.length === 0) { @@ -478,14 +443,18 @@ export class FeedModel { new FeedSliceModel(this.rootStore, `item-${_idCounter++}`, slice), ) if (autoPrepend) { - this.slices = nextSlicesModels.concat( - this.slices.filter(slice1 => - nextSlicesModels.find(slice2 => slice1.uri === slice2.uri), - ), - ) - this.setHasNewLatest(false) + runInAction(() => { + this.slices = nextSlicesModels.concat( + this.slices.filter(slice1 => + nextSlicesModels.find(slice2 => slice1.uri === slice2.uri), + ), + ) + this.setHasNewLatest(false) + }) } else { - this.nextSlices = nextSlicesModels + runInAction(() => { + this.nextSlices = nextSlicesModels + }) this.setHasNewLatest(true) } } else { @@ -519,13 +488,13 @@ export class FeedModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -538,14 +507,12 @@ export class FeedModel { // helper functions // = - private async _replaceAll( - res: GetTimeline.Response | GetAuthorFeed.Response, - ) { + async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) { this.pollCursor = res.data.feed[0]?.post.uri return this._appendAll(res, true) } - private async _appendAll( + async _appendAll( res: GetTimeline.Response | GetAuthorFeed.Response, replace = false, ) { @@ -572,7 +539,7 @@ export class FeedModel { }) } - private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { for (const item of res.data.feed) { const existingSlice = this.slices.find(slice => slice.containsUri(item.post.uri), @@ -596,7 +563,7 @@ export class FeedModel { const responses = await getMultipleAuthorsPosts( this.rootStore, sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20), - params.before, + params.cursor, 20, ) const combinedCursor = getCombinedCursors(responses) @@ -611,9 +578,7 @@ export class FeedModel { headers: lastHeaders, } } else if (this.feedType === 'home') { - return this.rootStore.api.app.bsky.feed.getTimeline( - params as GetTimeline.QueryParams, - ) + return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) } else if (this.feedType === 'goodstuff') { const res = await getGoodStuff( this.rootStore.session.currentSession?.accessJwt || '', @@ -624,7 +589,7 @@ export class FeedModel { ) return res } else { - return this.rootStore.api.app.bsky.feed.getAuthorFeed( + return this.rootStore.agent.getAuthorFeed( params as GetAuthorFeed.QueryParams, ) } diff --git a/src/state/models/votes-view.ts b/src/state/models/likes-view.ts index ad8698d21..5f9df692e 100644 --- a/src/state/models/votes-view.ts +++ b/src/state/models/likes-view.ts @@ -1,6 +1,6 @@ import {makeAutoObservable, runInAction} from 'mobx' import {AtUri} from '../../third-party/uri' -import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api' +import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from 'lib/strings/errors' import {bundleAsync} from 'lib/async/bundle' @@ -8,24 +8,24 @@ import * as apilib from 'lib/api/index' const PAGE_SIZE = 30 -export type VoteItem = GetVotes.Vote +export type LikeItem = GetLikes.Like -export class VotesViewModel { +export class LikesViewModel { // state isLoading = false isRefreshing = false hasLoaded = false error = '' resolvedUri = '' - params: GetVotes.QueryParams + params: GetLikes.QueryParams hasMore = true loadMoreCursor?: string // data uri: string = '' - votes: VoteItem[] = [] + likes: LikeItem[] = [] - constructor(public rootStore: RootStoreModel, params: GetVotes.QueryParams) { + constructor(public rootStore: RootStoreModel, params: GetLikes.QueryParams) { makeAutoObservable( this, { @@ -68,9 +68,9 @@ export class VotesViewModel { const params = Object.assign({}, this.params, { uri: this.resolvedUri, limit: PAGE_SIZE, - before: replace ? undefined : this.loadMoreCursor, + cursor: replace ? undefined : this.loadMoreCursor, }) - const res = await this.rootStore.api.app.bsky.feed.getVotes(params) + const res = await this.rootStore.agent.getLikes(params) if (replace) { this._replaceAll(res) } else { @@ -85,13 +85,13 @@ export class VotesViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -104,7 +104,7 @@ export class VotesViewModel { // helper functions // = - private async _resolveUri() { + async _resolveUri() { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { @@ -118,14 +118,14 @@ export class VotesViewModel { }) } - private _replaceAll(res: GetVotes.Response) { - this.votes = [] + _replaceAll(res: GetLikes.Response) { + this.likes = [] this._appendAll(res) } - private _appendAll(res: GetVotes.Response) { + _appendAll(res: GetLikes.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor - this.votes = this.votes.concat(res.data.votes) + this.likes = this.likes.concat(res.data.likes) } } diff --git a/src/state/models/log.ts b/src/state/models/log.ts index ed701dc61..d80617139 100644 --- a/src/state/models/log.ts +++ b/src/state/models/log.ts @@ -1,5 +1,5 @@ import {makeAutoObservable} from 'mobx' -import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' +// import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' TODO const MAX_ENTRIES = 300 @@ -32,7 +32,7 @@ export class LogModel { makeAutoObservable(this) } - private add(entry: LogEntry) { + add(entry: LogEntry) { this.entries.push(entry) while (this.entries.length > MAX_ENTRIES) { this.entries = this.entries.slice(50) @@ -79,14 +79,14 @@ export class LogModel { function detailsToStr(details?: any) { if (details && typeof details !== 'string') { if ( - details instanceof XRPCInvalidResponseError || + // details instanceof XRPCInvalidResponseError || TODO details.constructor.name === 'XRPCInvalidResponseError' ) { return `The server gave an ill-formatted response.\nMethod: ${ details.lexiconNsid }.\nError: ${details.validationError.toString()}` } else if ( - details instanceof XRPCError || + // details instanceof XRPCError || TODO details.constructor.name === 'XRPCError' ) { return `An XRPC error occurred.\nStatus: ${details.status}\nError: ${details.error}\nMessage: ${details.message}` diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 120749155..5f670b8f9 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -85,7 +85,7 @@ export class MeModel { if (sess.hasSession) { this.did = sess.currentSession?.did || '' this.handle = sess.currentSession?.handle || '' - const profile = await this.rootStore.api.app.bsky.actor.getProfile({ + const profile = await this.rootStore.agent.getProfile({ actor: this.did, }) runInAction(() => { diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts index e88af590b..4f7a52fd9 100644 --- a/src/state/models/notifications-view.ts +++ b/src/state/models/notifications-view.ts @@ -1,11 +1,10 @@ import {makeAutoObservable, runInAction} from 'mobx' import { - AppBskyNotificationList as ListNotifications, - AppBskyActorRef as ActorRef, + AppBskyNotificationListNotifications as ListNotifications, + AppBskyActorDefs, AppBskyFeedPost, AppBskyFeedRepost, - AppBskyFeedVote, - AppBskyGraphAssertion, + AppBskyFeedLike, AppBskyGraphFollow, } from '@atproto/api' import AwaitLock from 'await-lock' @@ -28,8 +27,7 @@ export interface GroupedNotification extends ListNotifications.Notification { type SupportedRecord = | AppBskyFeedPost.Record | AppBskyFeedRepost.Record - | AppBskyFeedVote.Record - | AppBskyGraphAssertion.Record + | AppBskyFeedLike.Record | AppBskyGraphFollow.Record export class NotificationsViewItemModel { @@ -39,11 +37,10 @@ export class NotificationsViewItemModel { // data uri: string = '' cid: string = '' - author: ActorRef.WithInfo = { + author: AppBskyActorDefs.ProfileViewBasic = { did: '', handle: '', avatar: '', - declaration: {cid: '', actorType: ''}, } reason: string = '' reasonSubject?: string @@ -86,8 +83,8 @@ export class NotificationsViewItemModel { } } - get isUpvote() { - return this.reason === 'vote' + get isLike() { + return this.reason === 'like' } get isRepost() { @@ -102,16 +99,22 @@ export class NotificationsViewItemModel { return this.reason === 'reply' } - get isFollow() { - return this.reason === 'follow' + get isQuote() { + return this.reason === 'quote' } - get isAssertion() { - return this.reason === 'assertion' + get isFollow() { + return this.reason === 'follow' } get needsAdditionalData() { - if (this.isUpvote || this.isRepost || this.isReply || this.isMention) { + if ( + this.isLike || + this.isRepost || + this.isReply || + this.isQuote || + this.isMention + ) { return !this.additionalPost } return false @@ -124,7 +127,7 @@ export class NotificationsViewItemModel { const record = this.record if ( AppBskyFeedRepost.isRecord(record) || - AppBskyFeedVote.isRecord(record) + AppBskyFeedLike.isRecord(record) ) { return record.subject.uri } @@ -135,8 +138,7 @@ export class NotificationsViewItemModel { for (const ns of [ AppBskyFeedPost, AppBskyFeedRepost, - AppBskyFeedVote, - AppBskyGraphAssertion, + AppBskyFeedLike, AppBskyGraphFollow, ]) { if (ns.isRecord(v)) { @@ -163,9 +165,9 @@ export class NotificationsViewItemModel { return } let postUri - if (this.isReply || this.isMention) { + if (this.isReply || this.isQuote || this.isMention) { postUri = this.uri - } else if (this.isUpvote || this.isRepost) { + } else if (this.isLike || this.isRepost) { postUri = this.subjectUri } if (postUri) { @@ -194,7 +196,7 @@ export class NotificationsViewModel { loadMoreCursor?: string // used to linearize async modifications to state - private lock = new AwaitLock() + lock = new AwaitLock() // data notifications: NotificationsViewItemModel[] = [] @@ -266,7 +268,7 @@ export class NotificationsViewModel { const params = Object.assign({}, this.params, { limit: PAGE_SIZE, }) - const res = await this.rootStore.api.app.bsky.notification.list(params) + const res = await this.rootStore.agent.listNotifications(params) await this._replaceAll(res) this._xIdle() } catch (e: any) { @@ -297,9 +299,9 @@ export class NotificationsViewModel { try { const params = Object.assign({}, this.params, { limit: PAGE_SIZE, - before: this.loadMoreCursor, + cursor: this.loadMoreCursor, }) - const res = await this.rootStore.api.app.bsky.notification.list(params) + const res = await this.rootStore.agent.listNotifications(params) await this._appendAll(res) this._xIdle() } catch (e: any) { @@ -325,7 +327,7 @@ export class NotificationsViewModel { try { this._xLoading() try { - const res = await this.rootStore.api.app.bsky.notification.list({ + const res = await this.rootStore.agent.listNotifications({ limit: PAGE_SIZE, }) await this._prependAll(res) @@ -357,8 +359,8 @@ export class NotificationsViewModel { try { do { const res: ListNotifications.Response = - await this.rootStore.api.app.bsky.notification.list({ - before: cursor, + await this.rootStore.agent.listNotifications({ + cursor, limit: Math.min(numToFetch, 100), }) if (res.data.notifications.length === 0) { @@ -390,7 +392,7 @@ export class NotificationsViewModel { */ loadUnreadCount = bundleAsync(async () => { const old = this.unreadCount - const res = await this.rootStore.api.app.bsky.notification.getCount() + const res = await this.rootStore.agent.countUnreadNotifications() runInAction(() => { this.unreadCount = res.data.count }) @@ -408,9 +410,7 @@ export class NotificationsViewModel { for (const notif of this.notifications) { notif.isRead = true } - await this.rootStore.api.app.bsky.notification.updateSeen({ - seenAt: new Date().toISOString(), - }) + await this.rootStore.agent.updateSeenNotifications() } catch (e: any) { this.rootStore.log.warn('Failed to update notifications read state', e) } @@ -418,7 +418,7 @@ export class NotificationsViewModel { async getNewMostRecent(): Promise<NotificationsViewItemModel | undefined> { let old = this.mostRecentNotificationUri - const res = await this.rootStore.api.app.bsky.notification.list({ + const res = await this.rootStore.agent.listNotifications({ limit: 1, }) if (!res.data.notifications[0] || old === res.data.notifications[0].uri) { @@ -437,13 +437,13 @@ export class NotificationsViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -456,14 +456,14 @@ export class NotificationsViewModel { // helper functions // = - private async _replaceAll(res: ListNotifications.Response) { + async _replaceAll(res: ListNotifications.Response) { if (res.data.notifications[0]) { this.mostRecentNotificationUri = res.data.notifications[0].uri } return this._appendAll(res, true) } - private async _appendAll(res: ListNotifications.Response, replace = false) { + async _appendAll(res: ListNotifications.Response, replace = false) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor const promises = [] @@ -494,7 +494,7 @@ export class NotificationsViewModel { }) } - private async _prependAll(res: ListNotifications.Response) { + async _prependAll(res: ListNotifications.Response) { const promises = [] const itemModels: NotificationsViewItemModel[] = [] const dedupedNotifs = res.data.notifications.filter( @@ -525,7 +525,7 @@ export class NotificationsViewModel { }) } - private _updateAll(res: ListNotifications.Response) { + _updateAll(res: ListNotifications.Response) { for (const item of res.data.notifications) { const existingItem = this.notifications.find(item2 => isEq(item, item2)) if (existingItem) { diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts index d58ee691b..c5395b9c8 100644 --- a/src/state/models/post-thread-view.ts +++ b/src/state/models/post-thread-view.ts @@ -2,12 +2,13 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetPostThread as GetPostThread, AppBskyFeedPost as FeedPost, + AppBskyFeedDefs, + RichText, } from '@atproto/api' import {AtUri} from '../../third-party/uri' import {RootStoreModel} from './root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' -import {RichText} from 'lib/strings/rich-text' function* reactKeyGenerator(): Generator<string> { let counter = 0 @@ -26,10 +27,10 @@ export class PostThreadViewPostModel { _hasMore = false // data - post: FeedPost.View + post: AppBskyFeedDefs.PostView postRecord?: FeedPost.Record - parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost - replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[] + parent?: PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost + replies?: (PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost)[] richText?: RichText get uri() { @@ -43,7 +44,7 @@ export class PostThreadViewPostModel { constructor( public rootStore: RootStoreModel, reactKey: string, - v: GetPostThread.ThreadViewPost, + v: AppBskyFeedDefs.ThreadViewPost, ) { this._reactKey = reactKey this.post = v.post @@ -51,11 +52,7 @@ export class PostThreadViewPostModel { const valid = FeedPost.validateRecord(this.post.record) if (valid.success) { this.postRecord = this.post.record - this.richText = new RichText( - this.postRecord.text, - this.postRecord.entities, - {cleanNewlines: true}, - ) + this.richText = new RichText(this.postRecord, {cleanNewlines: true}) } else { rootStore.log.warn( 'Received an invalid app.bsky.feed.post record', @@ -74,14 +71,14 @@ export class PostThreadViewPostModel { assignTreeModels( keyGen: Generator<string>, - v: GetPostThread.ThreadViewPost, + v: AppBskyFeedDefs.ThreadViewPost, higlightedPostUri: string, includeParent = true, includeChildren = true, ) { // parents if (includeParent && v.parent) { - if (GetPostThread.isThreadViewPost(v.parent)) { + if (AppBskyFeedDefs.isThreadViewPost(v.parent)) { const parentModel = new PostThreadViewPostModel( this.rootStore, keyGen.next().value, @@ -100,7 +97,7 @@ export class PostThreadViewPostModel { ) } this.parent = parentModel - } else if (GetPostThread.isNotFoundPost(v.parent)) { + } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) { this.parent = v.parent } } @@ -108,7 +105,7 @@ export class PostThreadViewPostModel { if (includeChildren && v.replies) { const replies = [] for (const item of v.replies) { - if (GetPostThread.isThreadViewPost(item)) { + if (AppBskyFeedDefs.isThreadViewPost(item)) { const itemModel = new PostThreadViewPostModel( this.rootStore, keyGen.next().value, @@ -128,7 +125,7 @@ export class PostThreadViewPostModel { ) } replies.push(itemModel) - } else if (GetPostThread.isNotFoundPost(item)) { + } else if (AppBskyFeedDefs.isNotFoundPost(item)) { replies.push(item) } } @@ -136,68 +133,43 @@ export class PostThreadViewPostModel { } } - async toggleUpvote() { - const wasUpvoted = !!this.post.viewer.upvote - const wasDownvoted = !!this.post.viewer.downvote - const res = await this.rootStore.api.app.bsky.feed.setVote({ - subject: { - uri: this.post.uri, - cid: this.post.cid, - }, - direction: wasUpvoted ? 'none' : 'up', - }) - runInAction(() => { - if (wasDownvoted) { - this.post.downvoteCount-- - } - if (wasUpvoted) { - this.post.upvoteCount-- - } else { - this.post.upvoteCount++ - } - this.post.viewer.upvote = res.data.upvote - this.post.viewer.downvote = res.data.downvote - }) - } - - async toggleDownvote() { - const wasUpvoted = !!this.post.viewer.upvote - const wasDownvoted = !!this.post.viewer.downvote - const res = await this.rootStore.api.app.bsky.feed.setVote({ - subject: { - uri: this.post.uri, - cid: this.post.cid, - }, - direction: wasDownvoted ? 'none' : 'down', - }) - runInAction(() => { - if (wasUpvoted) { - this.post.upvoteCount-- - } - if (wasDownvoted) { - this.post.downvoteCount-- - } else { - this.post.downvoteCount++ - } - this.post.viewer.upvote = res.data.upvote - this.post.viewer.downvote = res.data.downvote - }) + async toggleLike() { + if (this.post.viewer?.like) { + await this.rootStore.agent.deleteLike(this.post.viewer.like) + runInAction(() => { + this.post.likeCount = this.post.likeCount || 0 + this.post.viewer = this.post.viewer || {} + this.post.likeCount-- + this.post.viewer.like = undefined + }) + } else { + const res = await this.rootStore.agent.like(this.post.uri, this.post.cid) + runInAction(() => { + this.post.likeCount = this.post.likeCount || 0 + this.post.viewer = this.post.viewer || {} + this.post.likeCount++ + this.post.viewer.like = res.uri + }) + } } async toggleRepost() { - if (this.post.viewer.repost) { - await apilib.unrepost(this.rootStore, this.post.viewer.repost) + if (this.post.viewer?.repost) { + await this.rootStore.agent.deleteRepost(this.post.viewer.repost) runInAction(() => { + this.post.repostCount = this.post.repostCount || 0 + this.post.viewer = this.post.viewer || {} this.post.repostCount-- this.post.viewer.repost = undefined }) } else { - const res = await apilib.repost( - this.rootStore, + const res = await this.rootStore.agent.repost( this.post.uri, this.post.cid, ) runInAction(() => { + this.post.repostCount = this.post.repostCount || 0 + this.post.viewer = this.post.viewer || {} this.post.repostCount++ this.post.viewer.repost = res.uri }) @@ -205,10 +177,7 @@ export class PostThreadViewPostModel { } async delete() { - await this.rootStore.api.app.bsky.feed.post.delete({ - did: this.post.author.did, - rkey: new AtUri(this.post.uri).rkey, - }) + await this.rootStore.agent.deletePost(this.post.uri) this.rootStore.emitPostDeleted(this.post.uri) } } @@ -301,14 +270,14 @@ export class PostThreadViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' this.notFound = false } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -322,7 +291,7 @@ export class PostThreadViewModel { // loader functions // = - private async _resolveUri() { + async _resolveUri() { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { @@ -336,10 +305,10 @@ export class PostThreadViewModel { }) } - private async _load(isRefreshing = false) { + async _load(isRefreshing = false) { this._xLoading(isRefreshing) try { - const res = await this.rootStore.api.app.bsky.feed.getPostThread( + const res = await this.rootStore.agent.getPostThread( Object.assign({}, this.params, {uri: this.resolvedUri}), ) this._replaceAll(res) @@ -349,18 +318,18 @@ export class PostThreadViewModel { } } - private _replaceAll(res: GetPostThread.Response) { + _replaceAll(res: GetPostThread.Response) { sortThread(res.data.thread) const keyGen = reactKeyGenerator() const thread = new PostThreadViewPostModel( this.rootStore, keyGen.next().value, - res.data.thread as GetPostThread.ThreadViewPost, + res.data.thread as AppBskyFeedDefs.ThreadViewPost, ) thread._isHighlightedPost = true thread.assignTreeModels( keyGen, - res.data.thread as GetPostThread.ThreadViewPost, + res.data.thread as AppBskyFeedDefs.ThreadViewPost, thread.uri, ) this.thread = thread @@ -368,25 +337,25 @@ export class PostThreadViewModel { } type MaybePost = - | GetPostThread.ThreadViewPost - | GetPostThread.NotFoundPost + | AppBskyFeedDefs.ThreadViewPost + | AppBskyFeedDefs.NotFoundPost | {[k: string]: unknown; $type: string} function sortThread(post: MaybePost) { if (post.notFound) { return } - post = post as GetPostThread.ThreadViewPost + post = post as AppBskyFeedDefs.ThreadViewPost if (post.replies) { post.replies.sort((a: MaybePost, b: MaybePost) => { - post = post as GetPostThread.ThreadViewPost + post = post as AppBskyFeedDefs.ThreadViewPost if (a.notFound) { return 1 } if (b.notFound) { return -1 } - a = a as GetPostThread.ThreadViewPost - b = b as GetPostThread.ThreadViewPost + a = a as AppBskyFeedDefs.ThreadViewPost + b = b as AppBskyFeedDefs.ThreadViewPost const aIsByOp = a.post.author.did === post.post.author.did const bIsByOp = b.post.author.did === post.post.author.did if (aIsByOp && bIsByOp) { diff --git a/src/state/models/post.ts b/src/state/models/post.ts index 749e98bb0..c7f2896ba 100644 --- a/src/state/models/post.ts +++ b/src/state/models/post.ts @@ -58,12 +58,12 @@ export class PostModel implements RemoveIndex<Post.Record> { // state transitions // = - private _xLoading() { + _xLoading() { this.isLoading = true this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.hasLoaded = true this.error = cleanError(err) @@ -75,12 +75,12 @@ export class PostModel implements RemoveIndex<Post.Record> { // loader functions // = - private async _load() { + async _load() { this._xLoading() try { const urip = new AtUri(this.uri) - const res = await this.rootStore.api.app.bsky.feed.post.get({ - user: urip.host, + const res = await this.rootStore.agent.getPost({ + repo: urip.host, rkey: urip.rkey, }) // TODO @@ -94,7 +94,7 @@ export class PostModel implements RemoveIndex<Post.Record> { } } - private _replaceAll(res: Post.Record) { + _replaceAll(res: Post.Record) { this.text = res.text this.entities = res.entities this.reply = res.reply diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts index 9d3eeff58..eacc6a298 100644 --- a/src/state/models/profile-view.ts +++ b/src/state/models/profile-view.ts @@ -2,15 +2,12 @@ import {makeAutoObservable, runInAction} from 'mobx' import {PickedMedia} from 'lib/media/picker' import { AppBskyActorGetProfile as GetProfile, - AppBskySystemDeclRef, - AppBskyActorUpdateProfile, + AppBskyActorProfile, + RichText, } from '@atproto/api' -type DeclRef = AppBskySystemDeclRef.Main -import {extractEntities} from 'lib/strings/rich-text-detection' import {RootStoreModel} from './root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' -import {RichText} from 'lib/strings/rich-text' export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' @@ -35,22 +32,18 @@ export class ProfileViewModel { // data did: string = '' handle: string = '' - declaration: DeclRef = { - cid: '', - actorType: '', - } creator: string = '' - displayName?: string - description?: string - avatar?: string - banner?: string + displayName?: string = '' + description?: string = '' + avatar?: string = '' + banner?: string = '' followersCount: number = 0 followsCount: number = 0 postsCount: number = 0 viewer = new ProfileViewViewerModel() // added data - descriptionRichText?: RichText + descriptionRichText?: RichText = new RichText({text: ''}) constructor( public rootStore: RootStoreModel, @@ -79,10 +72,6 @@ export class ProfileViewModel { return this.hasLoaded && !this.hasContent } - get isUser() { - return this.declaration.actorType === ACTOR_TYPE_USER - } - // public api // = @@ -111,18 +100,14 @@ export class ProfileViewModel { } if (followUri) { - await apilib.unfollow(this.rootStore, followUri) + await this.rootStore.agent.deleteFollow(followUri) runInAction(() => { this.followersCount-- this.viewer.following = undefined this.rootStore.me.follows.removeFollow(this.did) }) } else { - const res = await apilib.follow( - this.rootStore, - this.did, - this.declaration.cid, - ) + const res = await this.rootStore.agent.follow(this.did) runInAction(() => { this.followersCount++ this.viewer.following = res.uri @@ -132,49 +117,48 @@ export class ProfileViewModel { } async updateProfile( - updates: AppBskyActorUpdateProfile.InputSchema, + updates: AppBskyActorProfile.Record, newUserAvatar: PickedMedia | undefined | null, newUserBanner: PickedMedia | undefined | null, ) { - if (newUserAvatar) { - const res = await apilib.uploadBlob( - this.rootStore, - newUserAvatar.path, - newUserAvatar.mime, - ) - updates.avatar = { - cid: res.data.cid, - mimeType: newUserAvatar.mime, + await this.rootStore.agent.upsertProfile(async existing => { + existing = existing || {} + existing.displayName = updates.displayName + existing.description = updates.description + if (newUserAvatar) { + const res = await apilib.uploadBlob( + this.rootStore, + newUserAvatar.path, + newUserAvatar.mime, + ) + existing.avatar = res.data.blob + } else if (newUserAvatar === null) { + existing.avatar = undefined } - } else if (newUserAvatar === null) { - updates.avatar = null - } - if (newUserBanner) { - const res = await apilib.uploadBlob( - this.rootStore, - newUserBanner.path, - newUserBanner.mime, - ) - updates.banner = { - cid: res.data.cid, - mimeType: newUserBanner.mime, + if (newUserBanner) { + const res = await apilib.uploadBlob( + this.rootStore, + newUserBanner.path, + newUserBanner.mime, + ) + existing.banner = res.data.blob + } else if (newUserBanner === null) { + existing.banner = undefined } - } else if (newUserBanner === null) { - updates.banner = null - } - await this.rootStore.api.app.bsky.actor.updateProfile(updates) + return existing + }) await this.rootStore.me.load() await this.refresh() } async muteAccount() { - await this.rootStore.api.app.bsky.graph.mute({user: this.did}) + await this.rootStore.agent.mute(this.did) this.viewer.muted = true await this.refresh() } async unmuteAccount() { - await this.rootStore.api.app.bsky.graph.unmute({user: this.did}) + await this.rootStore.agent.unmute(this.did) this.viewer.muted = false await this.refresh() } @@ -182,13 +166,13 @@ export class ProfileViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -201,40 +185,40 @@ export class ProfileViewModel { // loader functions // = - private async _load(isRefreshing = false) { + async _load(isRefreshing = false) { this._xLoading(isRefreshing) try { - const res = await this.rootStore.api.app.bsky.actor.getProfile( - this.params, - ) + const res = await this.rootStore.agent.getProfile(this.params) this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation this._replaceAll(res) + await this._createRichText() this._xIdle() } catch (e: any) { this._xIdle(e) } } - private _replaceAll(res: GetProfile.Response) { + _replaceAll(res: GetProfile.Response) { this.did = res.data.did this.handle = res.data.handle - Object.assign(this.declaration, res.data.declaration) - this.creator = res.data.creator this.displayName = res.data.displayName this.description = res.data.description this.avatar = res.data.avatar this.banner = res.data.banner - this.followersCount = res.data.followersCount - this.followsCount = res.data.followsCount - this.postsCount = res.data.postsCount + this.followersCount = res.data.followersCount || 0 + this.followsCount = res.data.followsCount || 0 + this.postsCount = res.data.postsCount || 0 if (res.data.viewer) { Object.assign(this.viewer, res.data.viewer) this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following) } + } + + async _createRichText() { this.descriptionRichText = new RichText( - this.description || '', - extractEntities(this.description || ''), + {text: this.description || ''}, {cleanNewlines: true}, ) + await this.descriptionRichText.detectFacets(this.rootStore.agent) } } diff --git a/src/state/models/profiles-view.ts b/src/state/models/profiles-view.ts index 4241e50e1..30e6d0442 100644 --- a/src/state/models/profiles-view.ts +++ b/src/state/models/profiles-view.ts @@ -31,7 +31,7 @@ export class ProfilesViewModel { } } try { - const promise = this.rootStore.api.app.bsky.actor.getProfile({ + const promise = this.rootStore.agent.getProfile({ actor: did, }) this.cache.set(did, promise) diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts index 69a728d6f..c9b089c70 100644 --- a/src/state/models/reposted-by-view.ts +++ b/src/state/models/reposted-by-view.ts @@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {AtUri} from '../../third-party/uri' import { AppBskyFeedGetRepostedBy as GetRepostedBy, - AppBskyActorRef as ActorRef, + AppBskyActorDefs, } from '@atproto/api' import {RootStoreModel} from './root-store' import {bundleAsync} from 'lib/async/bundle' @@ -11,7 +11,7 @@ import * as apilib from 'lib/api/index' const PAGE_SIZE = 30 -export type RepostedByItem = ActorRef.WithInfo +export type RepostedByItem = AppBskyActorDefs.ProfileViewBasic export class RepostedByViewModel { // state @@ -71,9 +71,9 @@ export class RepostedByViewModel { const params = Object.assign({}, this.params, { uri: this.resolvedUri, limit: PAGE_SIZE, - before: replace ? undefined : this.loadMoreCursor, + cursor: replace ? undefined : this.loadMoreCursor, }) - const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params) + const res = await this.rootStore.agent.getRepostedBy(params) if (replace) { this._replaceAll(res) } else { @@ -88,13 +88,13 @@ export class RepostedByViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -107,7 +107,7 @@ export class RepostedByViewModel { // helper functions // = - private async _resolveUri() { + async _resolveUri() { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { @@ -121,12 +121,12 @@ export class RepostedByViewModel { }) } - private _replaceAll(res: GetRepostedBy.Response) { + _replaceAll(res: GetRepostedBy.Response) { this.repostedBy = [] this._appendAll(res) } - private _appendAll(res: GetRepostedBy.Response) { + _appendAll(res: GetRepostedBy.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.repostedBy = this.repostedBy.concat(res.data.repostedBy) diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index d8336d005..0c2a31d28 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -2,8 +2,8 @@ * The root store is the base of all modeled state. */ -import {makeAutoObservable, runInAction} from 'mobx' -import {AtpAgent} from '@atproto/api' +import {makeAutoObservable} from 'mobx' +import {BskyAgent} from '@atproto/api' import {createContext, useContext} from 'react' import {DeviceEventEmitter, EmitterSubscription} from 'react-native' import * as BgScheduler from 'lib/bg-scheduler' @@ -29,7 +29,7 @@ export const appInfo = z.object({ export type AppInfo = z.infer<typeof appInfo> export class RootStoreModel { - agent: AtpAgent + agent: BskyAgent appInfo?: AppInfo log = new LogModel() session = new SessionModel(this) @@ -40,41 +40,16 @@ export class RootStoreModel { linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() - // HACK - // this flag is to track the lexicon breaking refactor - // it should be removed once we get that done - // -prf - hackUpgradeNeeded = false - async hackCheckIfUpgradeNeeded() { - try { - this.log.debug('hackCheckIfUpgradeNeeded()') - const res = await fetch('https://bsky.social/xrpc/app.bsky.feed.getLikes') - await res.text() - runInAction(() => { - this.hackUpgradeNeeded = res.status !== 501 - this.log.debug( - `hackCheckIfUpgradeNeeded() said ${this.hackUpgradeNeeded}`, - ) - }) - } catch (e) { - this.log.error('Failed to hackCheckIfUpgradeNeeded', {e}) - } - } - - constructor(agent: AtpAgent) { + constructor(agent: BskyAgent) { this.agent = agent makeAutoObservable(this, { - api: false, + agent: false, serialize: false, hydrate: false, }) this.initBgFetch() } - get api() { - return this.agent.api - } - setAppInfo(info: AppInfo) { this.appInfo = info } @@ -131,7 +106,7 @@ export class RootStoreModel { /** * Called by the session model. Refreshes session-oriented state. */ - async handleSessionChange(agent: AtpAgent) { + async handleSessionChange(agent: BskyAgent) { this.log.debug('RootStoreModel:handleSessionChange') this.agent = agent this.me.clear() @@ -259,7 +234,7 @@ export class RootStoreModel { async onBgFetch(taskId: string) { this.log.debug(`Background fetch fired for task ${taskId}`) if (this.session.hasSession) { - const res = await this.api.app.bsky.notification.getCount() + const res = await this.agent.countUnreadNotifications() const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count this.emitUnreadNotifications(res.data.count) this.log.debug( @@ -286,7 +261,7 @@ export class RootStoreModel { } const throwawayInst = new RootStoreModel( - new AtpAgent({service: 'http://localhost'}), + new BskyAgent({service: 'http://localhost'}), ) // this will be replaced by the loader, we just need to supply a value at init const RootStoreContext = createContext<RootStoreModel>(throwawayInst) export const RootStoreProvider = RootStoreContext.Provider diff --git a/src/state/models/session.ts b/src/state/models/session.ts index e131b2b2c..c2e10880d 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -1,9 +1,9 @@ import {makeAutoObservable, runInAction} from 'mobx' import { - AtpAgent, + BskyAgent, AtpSessionEvent, AtpSessionData, - ComAtprotoServerGetAccountsConfig as GetAccountsConfig, + ComAtprotoServerDescribeServer as DescribeServer, } from '@atproto/api' import normalizeUrl from 'normalize-url' import {isObj, hasProp} from 'lib/type-guards' @@ -11,7 +11,7 @@ import {networkRetry} from 'lib/async/retry' import {z} from 'zod' import {RootStoreModel} from './root-store' -export type ServiceDescription = GetAccountsConfig.OutputSchema +export type ServiceDescription = DescribeServer.OutputSchema export const activeSession = z.object({ service: z.string(), @@ -40,7 +40,7 @@ export class SessionModel { // emergency log facility to help us track down this logout issue // remove when resolved // -prf - private _log(message: string, details?: Record<string, any>) { + _log(message: string, details?: Record<string, any>) { details = details || {} details.state = { data: this.data, @@ -73,6 +73,7 @@ export class SessionModel { rootStore: false, serialize: false, hydrate: false, + hasSession: false, }) } @@ -154,7 +155,7 @@ export class SessionModel { /** * Sets the active session */ - async setActiveSession(agent: AtpAgent, did: string) { + async setActiveSession(agent: BskyAgent, did: string) { this._log('SessionModel:setActiveSession') this.data = { service: agent.service.toString(), @@ -166,7 +167,7 @@ export class SessionModel { /** * Upserts a session into the accounts */ - private persistSession( + persistSession( service: string, did: string, event: AtpSessionEvent, @@ -225,7 +226,7 @@ export class SessionModel { /** * Clears any session tokens from the accounts; used on logout. */ - private clearSessionTokens() { + clearSessionTokens() { this._log('SessionModel:clearSessionTokens') this.accounts = this.accounts.map(acct => ({ service: acct.service, @@ -239,10 +240,8 @@ export class SessionModel { /** * Fetches additional information about an account on load. */ - private async loadAccountInfo(agent: AtpAgent, did: string) { - const res = await agent.api.app.bsky.actor - .getProfile({actor: did}) - .catch(_e => undefined) + async loadAccountInfo(agent: BskyAgent, did: string) { + const res = await agent.getProfile({actor: did}).catch(_e => undefined) if (res) { return { dispayName: res.data.displayName, @@ -255,8 +254,8 @@ export class SessionModel { * Helper to fetch the accounts config settings from an account. */ async describeService(service: string): Promise<ServiceDescription> { - const agent = new AtpAgent({service}) - const res = await agent.api.com.atproto.server.getAccountsConfig({}) + const agent = new BskyAgent({service}) + const res = await agent.com.atproto.server.describeServer({}) return res.data } @@ -272,7 +271,7 @@ export class SessionModel { return false } - const agent = new AtpAgent({ + const agent = new BskyAgent({ service: account.service, persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => { this.persistSession(account.service, account.did, evt, sess) @@ -321,7 +320,7 @@ export class SessionModel { password: string }) { this._log('SessionModel:login') - const agent = new AtpAgent({service}) + const agent = new BskyAgent({service}) await agent.login({identifier, password}) if (!agent.session) { throw new Error('Failed to establish session') @@ -355,7 +354,7 @@ export class SessionModel { inviteCode?: string }) { this._log('SessionModel:createAccount') - const agent = new AtpAgent({service}) + const agent = new BskyAgent({service}) await agent.createAccount({ handle, password, @@ -389,7 +388,7 @@ export class SessionModel { // need to evaluate why deleting the session has caused errors at times // -prf /*if (this.hasSession) { - this.rootStore.api.com.atproto.session.delete().catch((e: any) => { + this.rootStore.agent.com.atproto.session.delete().catch((e: any) => { this.rootStore.log.warn( '(Minor issue) Failed to delete session on the server', e, @@ -415,7 +414,7 @@ export class SessionModel { if (!sess) { return } - const res = await this.rootStore.api.app.bsky.actor + const res = await this.rootStore.agent .getProfile({actor: sess.did}) .catch(_e => undefined) if (res?.success) { diff --git a/src/state/models/suggested-posts-view.ts b/src/state/models/suggested-posts-view.ts index 7a5ca81b9..46bf235ff 100644 --- a/src/state/models/suggested-posts-view.ts +++ b/src/state/models/suggested-posts-view.ts @@ -72,12 +72,12 @@ export class SuggestedPostsView { // state transitions // = - private _xLoading() { + _xLoading() { this.isLoading = true this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.hasLoaded = true this.error = cleanError(err) diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts index a212fe05e..e661cb59d 100644 --- a/src/state/models/ui/create-account.ts +++ b/src/state/models/ui/create-account.ts @@ -2,7 +2,7 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {ServiceDescription} from '../session' import {DEFAULT_SERVICE} from 'state/index' -import {ComAtprotoAccountCreate} from '@atproto/api' +import {ComAtprotoServerCreateAccount} from '@atproto/api' import * as EmailValidator from 'email-validator' import {createFullHandle} from 'lib/strings/handles' import {cleanError} from 'lib/strings/errors' @@ -99,7 +99,7 @@ export class CreateAccountModel { }) } catch (e: any) { let errMsg = e.toString() - if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { errMsg = 'Invite code not accepted. Check that you input it correctly and try again.' } diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 280541b74..59529aa39 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -40,7 +40,7 @@ export class ProfileUiModel { ) this.profile = new ProfileViewModel(rootStore, {actor: params.user}) this.feed = new FeedModel(rootStore, 'author', { - author: params.user, + actor: params.user, limit: 10, }) } @@ -64,16 +64,8 @@ export class ProfileUiModel { return this.profile.isRefreshing || this.currentView.isRefreshing } - get isUser() { - return this.profile.isUser - } - get selectorItems() { - if (this.isUser) { - return USER_SELECTOR_ITEMS - } else { - return USER_SELECTOR_ITEMS - } + return USER_SELECTOR_ITEMS } get selectedView() { diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts index 91e1b24bf..8436b0984 100644 --- a/src/state/models/ui/search.ts +++ b/src/state/models/ui/search.ts @@ -1,6 +1,6 @@ import {makeAutoObservable, runInAction} from 'mobx' import {searchProfiles, searchPosts} from 'lib/api/search' -import {AppBskyActorProfile as Profile} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' export class SearchUIModel { @@ -8,7 +8,7 @@ export class SearchUIModel { isProfilesLoading = false query: string = '' postUris: string[] = [] - profiles: Profile.View[] = [] + profiles: AppBskyActorDefs.ProfileView[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable(this) @@ -34,10 +34,10 @@ export class SearchUIModel { this.isPostsLoading = false }) - let profiles: Profile.View[] = [] + let profiles: AppBskyActorDefs.ProfileView[] = [] if (profilesSearch?.length) { do { - const res = await this.rootStore.api.app.bsky.actor.getProfiles({ + const res = await this.rootStore.agent.getProfiles({ actors: profilesSearch.splice(0, 25).map(p => p.did), }) profiles = profiles.concat(res.data.profiles) diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index fec1e2899..7f57d5b54 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,3 +1,4 @@ +import {AppBskyEmbedRecord} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable} from 'mobx' import {ProfileViewModel} from '../profile-view' @@ -111,6 +112,7 @@ export interface ComposerOptsQuote { displayName?: string avatar?: string } + embeds?: AppBskyEmbedRecord.ViewRecord['embeds'] } export interface ComposerOpts { replyTo?: ComposerOptsPostRef diff --git a/src/state/models/user-autocomplete-view.ts b/src/state/models/user-autocomplete-view.ts index 8e4211c27..ad89bb08b 100644 --- a/src/state/models/user-autocomplete-view.ts +++ b/src/state/models/user-autocomplete-view.ts @@ -1,5 +1,5 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorRef} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import AwaitLock from 'await-lock' import {RootStoreModel} from './root-store' @@ -11,8 +11,8 @@ export class UserAutocompleteViewModel { lock = new AwaitLock() // data - follows: AppBskyActorRef.WithInfo[] = [] - searchRes: AppBskyActorRef.WithInfo[] = [] + follows: AppBskyActorDefs.ProfileViewBasic[] = [] + searchRes: AppBskyActorDefs.ProfileViewBasic[] = [] knownHandles: Set<string> = new Set() constructor(public rootStore: RootStoreModel) { @@ -76,9 +76,9 @@ export class UserAutocompleteViewModel { // internal // = - private async _getFollows() { - const res = await this.rootStore.api.app.bsky.graph.getFollows({ - user: this.rootStore.me.did || '', + async _getFollows() { + const res = await this.rootStore.agent.getFollows({ + actor: this.rootStore.me.did || '', }) runInAction(() => { this.follows = res.data.follows @@ -88,13 +88,13 @@ export class UserAutocompleteViewModel { }) } - private async _search() { - const res = await this.rootStore.api.app.bsky.actor.searchTypeahead({ + async _search() { + const res = await this.rootStore.agent.searchActorsTypeahead({ term: this.prefix, limit: 8, }) runInAction(() => { - this.searchRes = res.data.users + this.searchRes = res.data.actors for (const u of this.searchRes) { this.knownHandles.add(u.handle) } diff --git a/src/state/models/user-followers-view.ts b/src/state/models/user-followers-view.ts index 7400262a4..055032eb7 100644 --- a/src/state/models/user-followers-view.ts +++ b/src/state/models/user-followers-view.ts @@ -1,7 +1,7 @@ import {makeAutoObservable} from 'mobx' import { AppBskyGraphGetFollowers as GetFollowers, - AppBskyActorRef as ActorRef, + AppBskyActorDefs as ActorDefs, } from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from 'lib/strings/errors' @@ -9,7 +9,7 @@ import {bundleAsync} from 'lib/async/bundle' const PAGE_SIZE = 30 -export type FollowerItem = ActorRef.WithInfo +export type FollowerItem = ActorDefs.ProfileViewBasic export class UserFollowersViewModel { // state @@ -22,10 +22,9 @@ export class UserFollowersViewModel { loadMoreCursor?: string // data - subject: ActorRef.WithInfo = { + subject: ActorDefs.ProfileViewBasic = { did: '', handle: '', - declaration: {cid: '', actorType: ''}, } followers: FollowerItem[] = [] @@ -71,9 +70,9 @@ export class UserFollowersViewModel { try { const params = Object.assign({}, this.params, { limit: PAGE_SIZE, - before: replace ? undefined : this.loadMoreCursor, + cursor: replace ? undefined : this.loadMoreCursor, }) - const res = await this.rootStore.api.app.bsky.graph.getFollowers(params) + const res = await this.rootStore.agent.getFollowers(params) if (replace) { this._replaceAll(res) } else { @@ -88,13 +87,13 @@ export class UserFollowersViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -107,12 +106,12 @@ export class UserFollowersViewModel { // helper functions // = - private _replaceAll(res: GetFollowers.Response) { + _replaceAll(res: GetFollowers.Response) { this.followers = [] this._appendAll(res) } - private _appendAll(res: GetFollowers.Response) { + _appendAll(res: GetFollowers.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.followers = this.followers.concat(res.data.followers) diff --git a/src/state/models/user-follows-view.ts b/src/state/models/user-follows-view.ts index 7d28d7ebd..6d9d84592 100644 --- a/src/state/models/user-follows-view.ts +++ b/src/state/models/user-follows-view.ts @@ -1,7 +1,7 @@ import {makeAutoObservable} from 'mobx' import { AppBskyGraphGetFollows as GetFollows, - AppBskyActorRef as ActorRef, + AppBskyActorDefs as ActorDefs, } from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from 'lib/strings/errors' @@ -9,7 +9,7 @@ import {bundleAsync} from 'lib/async/bundle' const PAGE_SIZE = 30 -export type FollowItem = ActorRef.WithInfo +export type FollowItem = ActorDefs.ProfileViewBasic export class UserFollowsViewModel { // state @@ -22,10 +22,9 @@ export class UserFollowsViewModel { loadMoreCursor?: string // data - subject: ActorRef.WithInfo = { + subject: ActorDefs.ProfileViewBasic = { did: '', handle: '', - declaration: {cid: '', actorType: ''}, } follows: FollowItem[] = [] @@ -71,9 +70,9 @@ export class UserFollowsViewModel { try { const params = Object.assign({}, this.params, { limit: PAGE_SIZE, - before: replace ? undefined : this.loadMoreCursor, + cursor: replace ? undefined : this.loadMoreCursor, }) - const res = await this.rootStore.api.app.bsky.graph.getFollows(params) + const res = await this.rootStore.agent.getFollows(params) if (replace) { this._replaceAll(res) } else { @@ -88,13 +87,13 @@ export class UserFollowsViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -107,12 +106,12 @@ export class UserFollowsViewModel { // helper functions // = - private _replaceAll(res: GetFollows.Response) { + _replaceAll(res: GetFollows.Response) { this.follows = [] this._appendAll(res) } - private _appendAll(res: GetFollows.Response) { + _appendAll(res: GetFollows.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.follows = this.follows.concat(res.data.follows) diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 618c15cf5..6ece903d6 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -75,16 +75,14 @@ export const CreateAccount = observer( {model.step === 3 && <Step3 model={model} />} </View> <View style={[s.flexRow, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBackInner}> + <TouchableOpacity onPress={onPressBackInner} testID="backBtn"> <Text type="xl" style={pal.link}> Back </Text> </TouchableOpacity> <View style={s.flex1} /> {model.canNext ? ( - <TouchableOpacity - testID="createAccountButton" - onPress={onPressNext}> + <TouchableOpacity testID="nextBtn" onPress={onPressNext}> {model.isProcessing ? ( <ActivityIndicator /> ) : ( @@ -95,7 +93,7 @@ export const CreateAccount = observer( </TouchableOpacity> ) : model.didServiceDescriptionFetchFail ? ( <TouchableOpacity - testID="registerRetryButton" + testID="retryConnectBtn" onPress={onPressRetryConnect}> <Text type="xl-bold" style={[pal.link, s.pr5]}> Retry diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 0a628f9d0..ca964ede2 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -60,12 +60,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { This is the company that keeps you online. </Text> <Option + testID="blueskyServerBtn" isSelected={isDefaultSelected} label="Bluesky" help=" (default)" onPress={onPressDefault} /> <Option + testID="otherServerBtn" isSelected={!isDefaultSelected} label="Other" onPress={onPressOther}> @@ -74,6 +76,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { Enter the address of your provider: </Text> <TextInput + testID="customServerInput" icon="globe" placeholder="Hosting provider address" value={model.serviceUrl} @@ -83,12 +86,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { {LOGIN_INCLUDE_DEV_SERVERS && ( <View style={[s.flexRow, s.mt10]}> <Button + testID="stagingServerBtn" type="default" style={s.mr5} label="Staging" onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} /> <Button + testID="localDevServerBtn" type="default" label="Dev Server" onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} @@ -112,11 +117,13 @@ function Option({ label, help, onPress, + testID, }: React.PropsWithChildren<{ isSelected: boolean label: string help?: string onPress: () => void + testID?: string }>) { const theme = useTheme() const pal = usePalette('default') @@ -129,7 +136,7 @@ function Option({ return ( <View style={[styles.option, pal.border]}> - <TouchableWithoutFeedback onPress={onPress}> + <TouchableWithoutFeedback onPress={onPress} testID={testID}> <View style={styles.optionHeading}> <View style={[styles.circle, pal.border]}> {isSelected ? ( diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index f115bf6ac..8df997bd3 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -59,6 +59,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { Email address </Text> <TextInput + testID="emailInput" icon="envelope" placeholder="Enter your email address" value={model.email} @@ -72,6 +73,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { Password </Text> <TextInput + testID="passwordInput" icon="lock" placeholder="Choose your password" value={model.password} @@ -86,7 +88,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { Legal check </Text> <TouchableOpacity - testID="registerIs13Input" + testID="is13Input" style={[styles.toggleBtn, pal.border]} onPress={() => model.setIs13(!model.is13)}> <View style={[pal.borderDark, styles.checkbox]}> diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index 652591171..13ab39a10 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -17,6 +17,7 @@ export const Step3 = observer(({model}: {model: CreateAccountModel}) => { <StepHeader step="3" title="Your user handle" /> <View style={s.pb10}> <TextInput + testID="handleInput" icon="at" placeholder="eg alice" value={model.handle} diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx index f99e72daa..eff1642f0 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -13,7 +13,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import * as EmailValidator from 'email-validator' -import AtpAgent from '@atproto/api' +import {BskyAgent} from '@atproto/api' import {useAnalytics} from 'lib/analytics' import {Text} from '../../util/text/Text' import {UserAvatar} from '../../util/UserAvatar' @@ -506,8 +506,8 @@ const ForgotPasswordForm = ({ setIsProcessing(true) try { - const agent = new AtpAgent({service: serviceUrl}) - await agent.api.com.atproto.account.requestPasswordReset({email}) + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.requestPasswordReset({email}) onEmailSent() } catch (e: any) { const errMsg = e.toString() @@ -648,8 +648,8 @@ const SetNewPasswordForm = ({ setIsProcessing(true) try { - const agent = new AtpAgent({service: serviceUrl}) - await agent.api.com.atproto.account.resetPassword({ + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.resetPassword({ token: resetCode, password, }) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 572eea927..6009debdd 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react' +import React from 'react' import {observer} from 'mobx-react-lite' import { ActivityIndicator, @@ -13,6 +13,7 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {ExternalEmbed} from './ExternalEmbed' @@ -30,11 +31,11 @@ import {SelectPhotoBtn} from './photos/SelectPhotoBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn' import {SelectedPhotos} from './photos/SelectedPhotos' import {usePalette} from 'lib/hooks/usePalette' -import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' +import QuoteEmbed from '../util/post-embeds/QuoteEmbed' import {useExternalLinkFetch} from './useExternalLinkFetch' import {isDesktopWeb} from 'platform/detection' -const MAX_TEXT_LENGTH = 256 +const MAX_GRAPHEME_LENGTH = 300 export const ComposePost = observer(function ComposePost({ replyTo, @@ -50,17 +51,23 @@ export const ComposePost = observer(function ComposePost({ const {track} = useAnalytics() const pal = usePalette('default') const store = useStores() - const textInput = useRef<TextInputRef>(null) - const [isProcessing, setIsProcessing] = useState(false) - const [processingState, setProcessingState] = useState('') - const [error, setError] = useState('') - const [text, setText] = useState('') - const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( + const textInput = React.useRef<TextInputRef>(null) + const [isProcessing, setIsProcessing] = React.useState(false) + const [processingState, setProcessingState] = React.useState('') + const [error, setError] = React.useState('') + const [richtext, setRichText] = React.useState(new RichText({text: ''})) + const graphemeLength = React.useMemo( + () => richtext.graphemeLength, + [richtext], + ) + const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>( initQuote, ) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) - const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) - const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]) + const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>( + new Set(), + ) + const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([]) const autocompleteView = React.useMemo<UserAutocompleteViewModel>( () => new UserAutocompleteViewModel(store), @@ -78,11 +85,11 @@ export const ComposePost = observer(function ComposePost({ }, [textInput, onClose]) // initial setup - useEffect(() => { + React.useEffect(() => { autocompleteView.setup() }, [autocompleteView]) - useEffect(() => { + React.useEffect(() => { // HACK // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view // -prf @@ -132,18 +139,18 @@ export const ComposePost = observer(function ComposePost({ if (isProcessing) { return } - if (text.length > MAX_TEXT_LENGTH) { + if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) { return } setError('') - if (text.trim().length === 0 && selectedPhotos.length === 0) { + if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) { setError('Did you want to say anything?') return false } setIsProcessing(true) try { await apilib.post(store, { - rawText: text, + rawText: richtext.text, replyTo: replyTo?.uri, images: selectedPhotos, quote: quote, @@ -172,7 +179,7 @@ export const ComposePost = observer(function ComposePost({ Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) }, [ isProcessing, - text, + richtext, setError, setIsProcessing, replyTo, @@ -187,7 +194,7 @@ export const ComposePost = observer(function ComposePost({ track, ]) - const canPost = text.length <= MAX_TEXT_LENGTH + const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH const selectTextInputPlaceholder = replyTo ? 'Write your reply' @@ -215,7 +222,7 @@ export const ComposePost = observer(function ComposePost({ </View> ) : canPost ? ( <TouchableOpacity - testID="composerPublishButton" + testID="composerPublishBtn" onPress={onPressPublish}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} @@ -271,42 +278,41 @@ export const ComposePost = observer(function ComposePost({ <UserAvatar avatar={store.me.avatar} size={50} /> <TextInput ref={textInput} - text={text} + richtext={richtext} placeholder={selectTextInputPlaceholder} suggestedLinks={suggestedLinks} autocompleteView={autocompleteView} - onTextChanged={setText} + setRichText={setRichText} onPhotoPasted={onPhotoPasted} onSuggestedLinksChanged={setSuggestedLinks} onError={setError} /> </View> - {quote ? ( - <View style={s.mt5}> - <QuoteEmbed quote={quote} /> - </View> - ) : undefined} - <SelectedPhotos selectedPhotos={selectedPhotos} onSelectPhotos={onSelectPhotos} /> - {!selectedPhotos.length && extLink && ( + {selectedPhotos.length === 0 && extLink && ( <ExternalEmbed link={extLink} onRemove={() => setExtLink(undefined)} /> )} + {quote ? ( + <View style={s.mt5}> + <QuoteEmbed quote={quote} /> + </View> + ) : undefined} </ScrollView> {!extLink && selectedPhotos.length === 0 && - suggestedLinks.size > 0 && - !quote ? ( + suggestedLinks.size > 0 ? ( <View style={s.mb5}> {Array.from(suggestedLinks).map(url => ( <TouchableOpacity key={`suggested-${url}`} + testID="addLinkCardBtn" style={[pal.borderDark, styles.addExtLinkBtn]} onPress={() => onPressAddLinkCard(url)}> <Text style={pal.text}> @@ -318,17 +324,17 @@ export const ComposePost = observer(function ComposePost({ ) : null} <View style={[pal.border, styles.bottomBar]}> <SelectPhotoBtn - enabled={!quote && selectedPhotos.length < 4} + enabled={selectedPhotos.length < 4} selectedPhotos={selectedPhotos} onSelectPhotos={setSelectedPhotos} /> <OpenCameraBtn - enabled={!quote && selectedPhotos.length < 4} + enabled={selectedPhotos.length < 4} selectedPhotos={selectedPhotos} onSelectPhotos={setSelectedPhotos} /> <View style={s.flex1} /> - <CharProgress count={text.length} /> + <CharProgress count={graphemeLength} /> </View> </SafeAreaView> </TouchableWithoutFeedback> @@ -408,6 +414,7 @@ const styles = StyleSheet.create({ borderRadius: 24, paddingHorizontal: 16, paddingVertical: 12, + marginHorizontal: 10, marginBottom: 4, }, bottomBar: { diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index b17cad1ba..eaaaea5e5 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -8,26 +8,24 @@ import ProgressPie from 'react-native-progress/Pie' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -const MAX_TEXT_LENGTH = 256 -const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH +const MAX_LENGTH = 300 +const DANGER_LENGTH = MAX_LENGTH export function CharProgress({count}: {count: number}) { const pal = usePalette('default') - const textColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.text - const circleColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.link + const textColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.text + const circleColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.link return ( <> - <Text style={[s.mr10, {color: textColor}]}> - {MAX_TEXT_LENGTH - count} - </Text> + <Text style={[s.mr10, {color: textColor}]}>{MAX_LENGTH - count}</Text> <View> - {count > DANGER_TEXT_LENGTH ? ( + {count > DANGER_LENGTH ? ( <ProgressPie size={30} borderWidth={4} borderColor={circleColor} color={circleColor} - progress={Math.min((count - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH, 1)} + progress={Math.min((count - MAX_LENGTH) / MAX_LENGTH, 1)} /> ) : ( <ProgressCircle @@ -35,7 +33,7 @@ export function CharProgress({count}: {count: number}) { borderWidth={1} borderColor={pal.colors.border} color={circleColor} - progress={count / MAX_TEXT_LENGTH} + progress={count / MAX_LENGTH} /> )} </View> diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index cf4a4c7d1..118728781 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -76,7 +76,11 @@ export function OpenCameraBtn({ hitSlop={HITSLOP}> <FontAwesomeIcon icon="camera" - style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + style={ + (enabled + ? pal.link + : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle + } size={24} /> </TouchableOpacity> diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index bdcb0534a..888118a85 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -86,7 +86,11 @@ export function SelectPhotoBtn({ hitSlop={HITSLOP}> <FontAwesomeIcon icon={['far', 'image']} - style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + style={ + (enabled + ? pal.link + : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle + } size={24} /> </TouchableOpacity> diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index e72b41f0a..393d168fe 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -9,13 +9,13 @@ import PasteInput, { PastedFile, PasteInputRef, } from '@mattermost/react-native-paste-input' +import {AppBskyRichtextFacet, RichText} from '@atproto/api' import isEqual from 'lodash.isequal' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {Autocomplete} from './mobile/Autocomplete' import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' import {cleanError} from 'lib/strings/errors' -import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' import {getImageDim} from 'lib/media/manip' import {cropAndCompressFlow} from 'lib/media/picker' import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' @@ -33,11 +33,11 @@ export interface TextInputRef { } interface TextInputProps { - text: string + richtext: RichText placeholder: string suggestedLinks: Set<string> autocompleteView: UserAutocompleteViewModel - onTextChanged: (v: string) => void + setRichText: (v: RichText) => void onPhotoPasted: (uri: string) => void onSuggestedLinksChanged: (uris: Set<string>) => void onError: (err: string) => void @@ -51,11 +51,11 @@ interface Selection { export const TextInput = React.forwardRef( ( { - text, + richtext, placeholder, suggestedLinks, autocompleteView, - onTextChanged, + setRichText, onPhotoPasted, onSuggestedLinksChanged, onError, @@ -92,7 +92,9 @@ export const TextInput = React.forwardRef( const onChangeText = React.useCallback( (newText: string) => { - onTextChanged(newText) + const newRt = new RichText({text: newText}) + newRt.detectFacetsWithoutResolution() + setRichText(newRt) const prefix = getMentionAt( newText, @@ -105,20 +107,21 @@ export const TextInput = React.forwardRef( autocompleteView.setActive(false) } - const ents = extractEntities(newText)?.filter( - ent => ent.type === 'link', - ) - const set = new Set(ents ? ents.map(e => e.value) : []) + const set: Set<string> = new Set() + if (newRt.facets) { + for (const facet of newRt.facets) { + for (const feature of facet.features) { + if (AppBskyRichtextFacet.isLink(feature)) { + set.add(feature.uri) + } + } + } + } if (!isEqual(set, suggestedLinks)) { onSuggestedLinksChanged(set) } }, - [ - onTextChanged, - autocompleteView, - suggestedLinks, - onSuggestedLinksChanged, - ], + [setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged], ) const onPaste = React.useCallback( @@ -159,31 +162,35 @@ export const TextInput = React.forwardRef( const onSelectAutocompleteItem = React.useCallback( (item: string) => { onChangeText( - insertMentionAt(text, textInputSelection.current?.start || 0, item), + insertMentionAt( + richtext.text, + textInputSelection.current?.start || 0, + item, + ), ) autocompleteView.setActive(false) }, - [onChangeText, text, autocompleteView], + [onChangeText, richtext, autocompleteView], ) const textDecorated = React.useMemo(() => { let i = 0 - return detectLinkables(text).map(v => { - if (typeof v === 'string') { + return Array.from(richtext.segments()).map(segment => { + if (!segment.facet) { return ( <Text key={i++} style={[pal.text, styles.textInputFormatting]}> - {v} + {segment.text} </Text> ) } else { return ( <Text key={i++} style={[pal.link, styles.textInputFormatting]}> - {v.link} + {segment.text} </Text> ) } }) - }, [text, pal.link, pal.text]) + }, [richtext, pal.link, pal.text]) return ( <View style={styles.container}> diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 4b23e891b..ad891fa5b 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,5 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' +import {RichText} from '@atproto/api' import {useEditor, EditorContent, JSONContent} from '@tiptap/react' import {Document} from '@tiptap/extension-document' import {Link} from '@tiptap/extension-link' @@ -17,11 +18,11 @@ export interface TextInputRef { } interface TextInputProps { - text: string + richtext: RichText placeholder: string suggestedLinks: Set<string> autocompleteView: UserAutocompleteViewModel - onTextChanged: (v: string) => void + setRichText: (v: RichText) => void onPhotoPasted: (uri: string) => void onSuggestedLinksChanged: (uris: Set<string>) => void onError: (err: string) => void @@ -30,11 +31,11 @@ interface TextInputProps { export const TextInput = React.forwardRef( ( { - text, + richtext, placeholder, suggestedLinks, autocompleteView, - onTextChanged, + setRichText, // onPhotoPasted, TODO onSuggestedLinksChanged, }: // onError, TODO @@ -60,15 +61,15 @@ export const TextInput = React.forwardRef( }), Text, ], - content: text, + content: richtext.text.toString(), autofocus: true, editable: true, injectCSS: true, onUpdate({editor: editorProp}) { const json = editorProp.getJSON() - const newText = editorJsonToText(json).trim() - onTextChanged(newText) + const newRt = new RichText({text: editorJsonToText(json).trim()}) + setRichText(newRt) const newSuggestedLinks = new Set(editorJsonToLinks(json)) if (!isEqual(newSuggestedLinks, suggestedLinks)) { diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index 0d09038ba..e4ada5204 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {AppBskyActorRef, AppBskyActorProfile} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {Text} from '../util/text/Text' @@ -12,9 +12,9 @@ export const SuggestedFollows = ({ }: { title: string suggestions: ( - | AppBskyActorRef.WithInfo + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileView | RefWithInfoAndFollowers - | AppBskyActorProfile.View )[] }) => { const pal = usePalette('default') @@ -28,7 +28,6 @@ export const SuggestedFollows = ({ <ProfileCardWithFollowBtn key={item.did} did={item.did} - declarationCid={item.declaration.cid} handle={item.handle} displayName={item.displayName} avatar={item.avatar} @@ -36,12 +35,12 @@ export const SuggestedFollows = ({ noBorder description={ item.description - ? (item as AppBskyActorProfile.View).description + ? (item as AppBskyActorDefs.ProfileView).description : '' } followers={ item.followers - ? (item.followers as AppBskyActorProfile.View[]) + ? (item.followers as AppBskyActorDefs.ProfileView[]) : undefined } /> diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index f15f7ca43..37bad6957 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -105,7 +105,7 @@ export function Component({onChanged}: {onChanged: () => void}) { track('EditHandle:SetNewHandle') const newHandle = isCustom ? handle : createFullHandle(handle, userDomain) store.log.debug(`Updating handle to ${newHandle}`) - await store.api.com.atproto.handle.update({ + await store.agent.updateHandle({ handle: newHandle, }) store.shell.closeModal() @@ -310,7 +310,7 @@ function CustomHandleForm({ try { setIsVerifying(true) setError('') - const res = await store.api.com.atproto.handle.resolve({handle}) + const res = await store.agent.com.atproto.identity.resolveHandle({handle}) if (res.data.did === store.me.did) { setCanSave(true) } else { @@ -331,7 +331,7 @@ function CustomHandleForm({ canSave, onPressSave, store.log, - store.api, + store.agent, ]) // rendering diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 60c104f99..2bfcf4118 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -39,7 +39,7 @@ export function Component({ } } return ( - <View style={[s.flex1, s.pl10, s.pr10]}> + <View testID="confirmModal" style={[s.flex1, s.pl10, s.pr10]}> <Text style={styles.title}>{title}</Text> {typeof message === 'string' ? ( <Text style={styles.description}>{message}</Text> @@ -56,7 +56,7 @@ export function Component({ <ActivityIndicator /> </View> ) : ( - <TouchableOpacity style={s.mt10} onPress={onPress}> + <TouchableOpacity testID="confirmBtn" style={s.mt10} onPress={onPress}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 23cd9eb82..353122163 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -32,7 +32,7 @@ export function Component({}: {}) { setError('') setIsProcessing(true) try { - await store.api.com.atproto.account.requestDelete() + await store.agent.com.atproto.server.requestAccountDelete() setIsEmailSent(true) } catch (e: any) { setError(cleanError(e)) @@ -43,7 +43,7 @@ export function Component({}: {}) { setError('') setIsProcessing(true) try { - await store.api.com.atproto.account.delete({ + await store.agent.com.atproto.server.deleteAccount({ did: store.me.did, password, token: confirmCode, diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 6eb21d17d..0b81d7f39 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -123,7 +123,7 @@ export function Component({ } return ( - <View style={[s.flex1, pal.view]}> + <View style={[s.flex1, pal.view]} testID="editProfileModal"> <ScrollView style={styles.inner}> <Text style={[styles.title, pal.text]}>Edit my profile</Text> <View style={styles.photos}> @@ -147,6 +147,7 @@ export function Component({ <View> <Text style={[styles.label, pal.text]}>Display Name</Text> <TextInput + testID="editProfileDisplayNameInput" style={[styles.textInput, pal.text]} placeholder="e.g. Alice Roberts" placeholderTextColor={colors.gray4} @@ -157,6 +158,7 @@ export function Component({ <View style={s.pb10}> <Text style={[styles.label, pal.text]}>Description</Text> <TextInput + testID="editProfileDescriptionInput" style={[styles.textArea, pal.text]} placeholder="e.g. Artist, dog-lover, and memelord." placeholderTextColor={colors.gray4} @@ -171,7 +173,10 @@ export function Component({ <ActivityIndicator /> </View> ) : ( - <TouchableOpacity style={s.mt10} onPress={onPressSave}> + <TouchableOpacity + testID="editProfileSaveBtn" + style={s.mt10} + onPress={onPressSave}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} @@ -181,7 +186,10 @@ export function Component({ </LinearGradient> </TouchableOpacity> )} - <TouchableOpacity style={s.mt5} onPress={onPressCancel}> + <TouchableOpacity + testID="editProfileCancelBtn" + style={s.mt5} + onPress={onPressCancel}> <View style={[styles.btn]}> <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> </View> diff --git a/src/view/com/modals/ReportAccount.tsx b/src/view/com/modals/ReportAccount.tsx index c9ee004b8..601bccbd1 100644 --- a/src/view/com/modals/ReportAccount.tsx +++ b/src/view/com/modals/ReportAccount.tsx @@ -5,7 +5,7 @@ import { TouchableOpacity, View, } from 'react-native' -import {ComAtprotoReportReasonType} from '@atproto/api' +import {ComAtprotoModerationDefs} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' import {useStores} from 'state/index' import {s, colors, gradients} from 'lib/styles' @@ -39,16 +39,16 @@ export function Component({did}: {did: string}) { setIsProcessing(true) try { // NOTE: we should update the lexicon of reasontype to include more options -prf - let reasonType = ComAtprotoReportReasonType.OTHER + let reasonType = ComAtprotoModerationDefs.REASONOTHER if (issue === 'spam') { - reasonType = ComAtprotoReportReasonType.SPAM + reasonType = ComAtprotoModerationDefs.REASONSPAM } const reason = ITEMS.find(item => item.key === issue)?.label || '' - await store.api.com.atproto.report.create({ + await store.agent.com.atproto.moderation.createReport({ reasonType, reason, subject: { - $type: 'com.atproto.repo.repoRef', + $type: 'com.atproto.admin.defs#repoRef', did, }, }) @@ -61,12 +61,18 @@ export function Component({did}: {did: string}) { } } return ( - <View style={[s.flex1, s.pl10, s.pr10, pal.view]}> + <View + testID="reportAccountModal" + style={[s.flex1, s.pl10, s.pr10, pal.view]}> <Text style={[pal.text, styles.title]}>Report account</Text> <Text style={[pal.textLight, styles.description]}> What is the issue with this account? </Text> - <RadioGroup items={ITEMS} onSelect={onSelectIssue} /> + <RadioGroup + testID="reportAccountRadios" + items={ITEMS} + onSelect={onSelectIssue} + /> {error ? ( <View style={s.mt10}> <ErrorMessage message={error} /> @@ -77,7 +83,10 @@ export function Component({did}: {did: string}) { <ActivityIndicator /> </View> ) : issue ? ( - <TouchableOpacity style={s.mt10} onPress={onPress}> + <TouchableOpacity + testID="sendReportBtn" + style={s.mt10} + onPress={onPress}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/ReportPost.tsx b/src/view/com/modals/ReportPost.tsx index 3e876c6c8..01a132af0 100644 --- a/src/view/com/modals/ReportPost.tsx +++ b/src/view/com/modals/ReportPost.tsx @@ -5,7 +5,7 @@ import { TouchableOpacity, View, } from 'react-native' -import {ComAtprotoReportReasonType} from '@atproto/api' +import {ComAtprotoModerationDefs} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' import {useStores} from 'state/index' import {s, colors, gradients} from 'lib/styles' @@ -46,16 +46,16 @@ export function Component({ setIsProcessing(true) try { // NOTE: we should update the lexicon of reasontype to include more options -prf - let reasonType = ComAtprotoReportReasonType.OTHER + let reasonType = ComAtprotoModerationDefs.REASONOTHER if (issue === 'spam') { - reasonType = ComAtprotoReportReasonType.SPAM + reasonType = ComAtprotoModerationDefs.REASONSPAM } const reason = ITEMS.find(item => item.key === issue)?.label || '' - await store.api.com.atproto.report.create({ + await store.agent.createModerationReport({ reasonType, reason, subject: { - $type: 'com.atproto.repo.recordRef', + $type: 'com.atproto.repo.strongRef', uri: postUri, cid: postCid, }, @@ -69,12 +69,16 @@ export function Component({ } } return ( - <View style={[s.flex1, s.pl10, s.pr10, pal.view]}> + <View testID="reportPostModal" style={[s.flex1, s.pl10, s.pr10, pal.view]}> <Text style={[pal.text, styles.title]}>Report post</Text> <Text style={[pal.textLight, styles.description]}> What is the issue with this post? </Text> - <RadioGroup items={ITEMS} onSelect={onSelectIssue} /> + <RadioGroup + testID="reportPostRadios" + items={ITEMS} + onSelect={onSelectIssue} + /> {error ? ( <View style={s.mt10}> <ErrorMessage message={error} /> @@ -85,7 +89,10 @@ export function Component({ <ActivityIndicator /> </View> ) : issue ? ( - <TouchableOpacity style={s.mt10} onPress={onPress}> + <TouchableOpacity + testID="sendReportBtn" + style={s.mt10} + onPress={onPress}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx index b4669a046..d5ed66b70 100644 --- a/src/view/com/modals/Repost.tsx +++ b/src/view/com/modals/Repost.tsx @@ -26,22 +26,28 @@ export function Component({ } return ( - <View style={[s.flex1, pal.view, styles.container]}> + <View testID="repostModal" style={[s.flex1, pal.view, styles.container]}> <View style={s.pb20}> - <TouchableOpacity style={[styles.actionBtn]} onPress={onRepost}> + <TouchableOpacity + testID="repostBtn" + style={[styles.actionBtn]} + onPress={onRepost}> <RepostIcon strokeWidth={2} size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> {!isReposted ? 'Repost' : 'Undo repost'} </Text> </TouchableOpacity> - <TouchableOpacity style={[styles.actionBtn]} onPress={onQuote}> + <TouchableOpacity + testID="quoteBtn" + style={[styles.actionBtn]} + onPress={onQuote}> <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> Quote Post </Text> </TouchableOpacity> </View> - <TouchableOpacity onPress={onPress}> + <TouchableOpacity testID="cancelBtn" onPress={onPress}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 1c2299b03..7d584e8e6 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -47,10 +47,10 @@ export const FeedItem = observer(function FeedItem({ const pal = usePalette('default') const [isAuthorsExpanded, setAuthorsExpanded] = React.useState<boolean>(false) const itemHref = React.useMemo(() => { - if (item.isUpvote || item.isRepost) { + if (item.isLike || item.isRepost) { const urip = new AtUri(item.subjectUri) return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.isFollow || item.isAssertion) { + } else if (item.isFollow) { return `/profile/${item.author.handle}` } else if (item.isReply) { const urip = new AtUri(item.uri) @@ -59,9 +59,9 @@ export const FeedItem = observer(function FeedItem({ return '' }, [item]) const itemTitle = React.useMemo(() => { - if (item.isUpvote || item.isRepost) { + if (item.isLike || item.isRepost) { return 'Post' - } else if (item.isFollow || item.isAssertion) { + } else if (item.isFollow) { return item.author.handle } else if (item.isReply) { return 'Post' @@ -77,7 +77,7 @@ export const FeedItem = observer(function FeedItem({ return <View /> } - if (item.isReply || item.isMention) { + if (item.isReply || item.isMention || item.isQuote) { if (item.additionalPost?.error) { // hide errors - it doesnt help the user to show them return <View /> @@ -103,7 +103,7 @@ export const FeedItem = observer(function FeedItem({ let action = '' let icon: Props['icon'] | 'HeartIconSolid' let iconStyle: Props['style'] = [] - if (item.isUpvote) { + if (item.isLike) { action = 'liked your post' icon = 'HeartIconSolid' iconStyle = [ @@ -114,9 +114,6 @@ export const FeedItem = observer(function FeedItem({ action = 'reposted your post' icon = 'retweet' iconStyle = [s.green3 as FontAwesomeIconStyle] - } else if (item.isReply) { - action = 'replied to your post' - icon = ['far', 'comment'] } else if (item.isFollow) { action = 'followed you' icon = 'user-plus' @@ -208,7 +205,7 @@ export const FeedItem = observer(function FeedItem({ </View> </View> </TouchableWithoutFeedback> - {item.isUpvote || item.isRepost ? ( + {item.isLike || item.isRepost || item.isQuote ? ( <AdditionalPostText additionalPost={item.additionalPost} /> ) : ( <></> @@ -352,9 +349,9 @@ function AdditionalPostText({ return <View /> } const text = additionalPost.thread?.postRecord.text - const images = ( - additionalPost.thread.post.embed as AppBskyEmbedImages.Presented - )?.images + const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed) + ? additionalPost.thread.post.embed.images + : undefined return ( <> {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} diff --git a/src/view/com/pager/FeedsTabBar.tsx b/src/view/com/pager/FeedsTabBar.tsx index 9831218ec..76e0a6fc6 100644 --- a/src/view/com/pager/FeedsTabBar.tsx +++ b/src/view/com/pager/FeedsTabBar.tsx @@ -9,7 +9,9 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' export const FeedsTabBar = observer( - (props: RenderTabBarFnProps & {onPressSelected: () => void}) => { + ( + props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, + ) => { const store = useStores() const pal = usePalette('default') const interp = useAnimatedValue(0) @@ -32,7 +34,10 @@ export const FeedsTabBar = observer( return ( <Animated.View style={[pal.view, styles.tabBar, transform]}> - <TouchableOpacity style={styles.tabBarAvi} onPress={onPressAvi}> + <TouchableOpacity + testID="viewHeaderDrawerBtn" + style={styles.tabBarAvi} + onPress={onPressAvi}> <UserAvatar avatar={store.me.avatar} size={30} /> </TouchableOpacity> <TabBar diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 416828a27..34747db6d 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -20,6 +20,7 @@ interface Props { initialPage?: number renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void + testID?: string } export const Pager = ({ children, @@ -27,6 +28,7 @@ export const Pager = ({ initialPage = 0, renderTabBar, onPageSelected, + testID, }: React.PropsWithChildren<Props>) => { const [selectedPage, setSelectedPage] = React.useState(0) const position = useAnimatedValue(0) @@ -49,7 +51,7 @@ export const Pager = ({ ) return ( - <View> + <View testID={testID}> {tabBarPosition === 'top' && renderTabBar({ selectedPage, diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 0b45d95f5..2070898bf 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -15,6 +15,7 @@ interface Layout { } export interface TabBarProps { + testID?: string selectedPage: number items: string[] position: Animated.Value @@ -26,6 +27,7 @@ export interface TabBarProps { } export function TabBar({ + testID, selectedPage, items, position, @@ -92,12 +94,15 @@ export function TabBar({ } return ( - <View style={[pal.view, styles.outer]} onLayout={onLayout}> + <View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}> <Animated.View style={[styles.indicator, indicatorStyle]} /> {items.map((item, i) => { const selected = i === selectedPage return ( - <TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}> + <TouchableWithoutFeedback + key={i} + testID={testID ? `${testID}-${item}` : undefined} + onPress={() => onPressItem(i)}> <View style={ indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom diff --git a/src/view/com/post-thread/PostVotedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index f86798097..9fb46702e 100644 --- a/src/view/com/post-thread/PostVotedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -2,24 +2,18 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import {VotesViewModel, VoteItem} from 'state/models/votes-view' +import {LikesViewModel, LikeItem} from 'state/models/likes-view' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -export const PostVotedBy = observer(function PostVotedBy({ - uri, - direction, -}: { - uri: string - direction: 'up' | 'down' -}) { +export const PostLikedBy = observer(function PostVotedBy({uri}: {uri: string}) { const pal = usePalette('default') const store = useStores() const view = React.useMemo( - () => new VotesViewModel(store, {uri, direction}), - [store, uri, direction], + () => new LikesViewModel(store, {uri}), + [store, uri], ) useEffect(() => { @@ -55,11 +49,10 @@ export const PostVotedBy = observer(function PostVotedBy({ // loaded // = - const renderItem = ({item}: {item: VoteItem}) => ( + const renderItem = ({item}: {item: LikeItem}) => ( <ProfileCardWithFollowBtn key={item.actor.did} did={item.actor.did} - declarationCid={item.actor.declaration.cid} handle={item.actor.handle} displayName={item.actor.displayName} avatar={item.actor.avatar} @@ -68,7 +61,7 @@ export const PostVotedBy = observer(function PostVotedBy({ ) return ( <FlatList - data={view.votes} + data={view.likes} keyExtractor={item => item.actor.did} refreshControl={ <RefreshControl diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index fda54469c..147d0271f 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -64,7 +64,6 @@ export const PostRepostedBy = observer(function PostRepostedBy({ <ProfileCardWithFollowBtn key={item.did} did={item.did} - declarationCid={item.declaration.cid} handle={item.handle} displayName={item.displayName} avatar={item.avatar} diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index d0452331b..569c6e392 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,17 +1,30 @@ import React, {useRef} from 'react' import {observer} from 'mobx-react-lite' -import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import { + ActivityIndicator, + RefreshControl, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' import {CenteredView, FlatList} from '../util/Views' import { PostThreadViewModel, PostThreadViewPostModel, } from 'state/models/post-thread-view' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' import {PostThreadItem} from './PostThreadItem' import {ComposePrompt} from '../composer/Prompt' import {ErrorMessage} from '../util/error/ErrorMessage' +import {Text} from '../util/text/Text' import {s} from 'lib/styles' import {isDesktopWeb} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} const BOTTOM_BORDER = { @@ -32,6 +45,7 @@ export const PostThread = observer(function PostThread({ const pal = usePalette('default') const ref = useRef<FlatList>(null) const [isRefreshing, setIsRefreshing] = React.useState(false) + const navigation = useNavigation<NavigationProp>() const posts = React.useMemo(() => { if (view.thread) { return Array.from(flattenThread(view.thread)).concat([BOTTOM_BORDER]) @@ -41,6 +55,7 @@ export const PostThread = observer(function PostThread({ // events // = + const onRefresh = React.useCallback(async () => { setIsRefreshing(true) try { @@ -50,6 +65,7 @@ export const PostThread = observer(function PostThread({ } setIsRefreshing(false) }, [view, setIsRefreshing]) + const onLayout = React.useCallback(() => { const index = posts.findIndex(post => post._isHighlightedPost) if (index !== -1) { @@ -60,6 +76,7 @@ export const PostThread = observer(function PostThread({ }) } }, [posts, ref]) + const onScrollToIndexFailed = React.useCallback( (info: { index: number @@ -73,6 +90,15 @@ export const PostThread = observer(function PostThread({ }, [ref], ) + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + const renderItem = React.useCallback( ({item}: {item: YieldedItem}) => { if (item === REPLY_PROMPT) { @@ -104,6 +130,30 @@ export const PostThread = observer(function PostThread({ // error // = if (view.hasError) { + if (view.notFound) { + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb5]}> + Post not found + </Text> + <Text type="md" style={[pal.text, s.mb10]}> + The post may have been deleted. + </Text> + <TouchableOpacity onPress={onPressBack}> + <Text type="2xl" style={pal.link}> + <FontAwesomeIcon + icon="angle-left" + style={[pal.link as FontAwesomeIconStyle, s.mr5]} + size={14} + /> + Back + </Text> + </TouchableOpacity> + </View> + </CenteredView> + ) + } return ( <CenteredView> <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> @@ -159,12 +209,18 @@ function* flattenThread( yield* flattenThread(reply as PostThreadViewPostModel) } } - } else if (!isAscending && !post.parent && post.post.replyCount > 0) { + } else if (!isAscending && !post.parent && post.post.replyCount) { post._hasMore = true } } const styles = StyleSheet.create({ + notFoundContainer: { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, bottomBorder: { borderBottomWidth: 1, }, diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 17c7943d9..cf2148060 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -19,7 +19,7 @@ import {ago} from 'lib/strings/time' import {pluralize} from 'lib/strings/helpers' import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' -import {PostEmbeds} from '../util/PostEmbeds' +import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/PostCtrls' import {PostMutedWrapper} from '../util/PostMuted' import {ErrorMessage} from '../util/error/ErrorMessage' @@ -38,7 +38,7 @@ export const PostThreadItem = observer(function PostThreadItem({ const store = useStores() const [deleted, setDeleted] = React.useState(false) const record = item.postRecord - const hasEngagement = item.post.upvoteCount || item.post.repostCount + const hasEngagement = item.post.likeCount || item.post.repostCount const itemUri = item.post.uri const itemCid = item.post.cid @@ -49,11 +49,11 @@ export const PostThreadItem = observer(function PostThreadItem({ const itemTitle = `Post by ${item.post.author.handle}` const authorHref = `/profile/${item.post.author.handle}` const authorTitle = item.post.author.handle - const upvotesHref = React.useMemo(() => { + const likesHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) - return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by` + return `/profile/${item.post.author.handle}/post/${urip.rkey}/liked-by` }, [item.post.uri, item.post.author.handle]) - const upvotesTitle = 'Likes on this post' + const likesTitle = 'Likes on this post' const repostsHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by` @@ -80,10 +80,10 @@ export const PostThreadItem = observer(function PostThreadItem({ .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) }, [item, store]) - const onPressToggleUpvote = React.useCallback(() => { + const onPressToggleLike = React.useCallback(() => { return item - .toggleUpvote() - .catch(e => store.log.error('Failed to toggle upvote', e)) + .toggleLike() + .catch(e => store.log.error('Failed to toggle like', e)) }, [item, store]) const onCopyPostText = React.useCallback(() => { Clipboard.setString(record?.text || '') @@ -125,153 +125,151 @@ export const PostThreadItem = observer(function PostThreadItem({ if (item._isHighlightedPost) { return ( - <> - <View - style={[ - styles.outer, - styles.outerHighlighted, - {borderTopColor: pal.colors.border}, - pal.view, - ]}> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <Link href={authorHref} title={authorTitle} asAnchor> - <UserAvatar size={52} avatar={item.post.author.avatar} /> - </Link> - </View> - <View style={styles.layoutContent}> - <View style={[styles.meta, styles.metaExpandedLine1]}> - <View style={[s.flexRow, s.alignBaseline]}> - <Link - style={styles.metaItem} - href={authorHref} - title={authorTitle}> - <Text - type="xl-bold" - style={[pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {item.post.author.displayName || item.post.author.handle} - </Text> - </Link> - <Text type="md" style={[styles.metaItem, pal.textLight]}> - · {ago(item.post.indexedAt)} - </Text> - </View> - <View style={s.flex1} /> - <PostDropdownBtn - style={styles.metaItem} - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - isAuthor={item.post.author.did === store.me.did} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onDeletePost={onDeletePost}> - <FontAwesomeIcon - icon="ellipsis-h" - size={14} - style={[s.mt2, s.mr5, pal.textLight]} - /> - </PostDropdownBtn> - </View> - <View style={styles.meta}> + <View + testID={`postThreadItem-by-${item.post.author.handle}`} + style={[ + styles.outer, + styles.outerHighlighted, + {borderTopColor: pal.colors.border}, + pal.view, + ]}> + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <Link href={authorHref} title={authorTitle} asAnchor> + <UserAvatar size={52} avatar={item.post.author.avatar} /> + </Link> + </View> + <View style={styles.layoutContent}> + <View style={[styles.meta, styles.metaExpandedLine1]}> + <View style={[s.flexRow, s.alignBaseline]}> <Link style={styles.metaItem} href={authorHref} title={authorTitle}> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - @{item.post.author.handle} + <Text + type="xl-bold" + style={[pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {item.post.author.displayName || item.post.author.handle} </Text> </Link> + <Text type="md" style={[styles.metaItem, pal.textLight]}> + · {ago(item.post.indexedAt)} + </Text> </View> - </View> - </View> - <View style={[s.pl10, s.pr10, s.pb10]}> - {item.richText?.text ? ( - <View - style={[ - styles.postTextContainer, - styles.postTextLargeContainer, - ]}> - <RichText - type="post-text-lg" - richText={item.richText} - lineHeight={1.3} - /> - </View> - ) : undefined} - <PostEmbeds embed={item.post.embed} style={s.mb10} /> - {item._isHighlightedPost && hasEngagement ? ( - <View style={[styles.expandedInfo, pal.border]}> - {item.post.repostCount ? ( - <Link - style={styles.expandedInfoItem} - href={repostsHref} - title={repostsTitle}> - <Text type="lg" style={pal.textLight}> - <Text type="xl-bold" style={pal.text}> - {item.post.repostCount} - </Text>{' '} - {pluralize(item.post.repostCount, 'repost')} - </Text> - </Link> - ) : ( - <></> - )} - {item.post.upvoteCount ? ( - <Link - style={styles.expandedInfoItem} - href={upvotesHref} - title={upvotesTitle}> - <Text type="lg" style={pal.textLight}> - <Text type="xl-bold" style={pal.text}> - {item.post.upvoteCount} - </Text>{' '} - {pluralize(item.post.upvoteCount, 'like')} - </Text> - </Link> - ) : ( - <></> - )} - </View> - ) : ( - <></> - )} - <View style={[s.pl10, s.pb5]}> - <PostCtrls - big + <View style={s.flex1} /> + <PostDropdownBtn + testID="postDropdownBtn" + style={styles.metaItem} itemUri={itemUri} itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} - author={{ - avatar: item.post.author.avatar!, - handle: item.post.author.handle, - displayName: item.post.author.displayName!, - }} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} isAuthor={item.post.author.did === store.me.did} - isReposted={!!item.post.viewer.repost} - isUpvoted={!!item.post.viewer.upvote} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleUpvote={onPressToggleUpvote} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} - onDeletePost={onDeletePost} + onDeletePost={onDeletePost}> + <FontAwesomeIcon + icon="ellipsis-h" + size={14} + style={[s.mt2, s.mr5, pal.textLight]} + /> + </PostDropdownBtn> + </View> + <View style={styles.meta}> + <Link + style={styles.metaItem} + href={authorHref} + title={authorTitle}> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + @{item.post.author.handle} + </Text> + </Link> + </View> + </View> + </View> + <View style={[s.pl10, s.pr10, s.pb10]}> + {item.richText?.text ? ( + <View + style={[styles.postTextContainer, styles.postTextLargeContainer]}> + <RichText + type="post-text-lg" + richText={item.richText} + lineHeight={1.3} /> </View> + ) : undefined} + <PostEmbeds embed={item.post.embed} style={s.mb10} /> + {item._isHighlightedPost && hasEngagement ? ( + <View style={[styles.expandedInfo, pal.border]}> + {item.post.repostCount ? ( + <Link + style={styles.expandedInfoItem} + href={repostsHref} + title={repostsTitle}> + <Text testID="repostCount" type="lg" style={pal.textLight}> + <Text type="xl-bold" style={pal.text}> + {item.post.repostCount} + </Text>{' '} + {pluralize(item.post.repostCount, 'repost')} + </Text> + </Link> + ) : ( + <></> + )} + {item.post.likeCount ? ( + <Link + style={styles.expandedInfoItem} + href={likesHref} + title={likesTitle}> + <Text testID="likeCount" type="lg" style={pal.textLight}> + <Text type="xl-bold" style={pal.text}> + {item.post.likeCount} + </Text>{' '} + {pluralize(item.post.likeCount, 'like')} + </Text> + </Link> + ) : ( + <></> + )} + </View> + ) : ( + <></> + )} + <View style={[s.pl10, s.pb5]}> + <PostCtrls + big + itemUri={itemUri} + itemCid={itemCid} + itemHref={itemHref} + itemTitle={itemTitle} + author={{ + avatar: item.post.author.avatar!, + handle: item.post.author.handle, + displayName: item.post.author.displayName!, + }} + text={item.richText?.text || record.text} + indexedAt={item.post.indexedAt} + isAuthor={item.post.author.did === store.me.did} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} + onPressReply={onPressReply} + onPressToggleRepost={onPressToggleRepost} + onPressToggleLike={onPressToggleLike} + onCopyPostText={onCopyPostText} + onOpenTranslate={onOpenTranslate} + onDeletePost={onDeletePost} + /> </View> </View> - </> + </View> ) } else { return ( <PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}> <Link + testID={`postThreadItem-by-${item.post.author.handle}`} style={[styles.outer, {borderTopColor: pal.colors.border}, pal.view]} href={itemHref} title={itemTitle} @@ -305,7 +303,6 @@ export const PostThreadItem = observer(function PostThreadItem({ timestamp={item.post.indexedAt} postHref={itemHref} did={item.post.author.did} - declarationCid={item.post.author.declaration.cid} /> {item.richText?.text ? ( <View style={styles.postTextContainer}> @@ -333,12 +330,12 @@ export const PostThreadItem = observer(function PostThreadItem({ isAuthor={item.post.author.did === store.me.did} replyCount={item.post.replyCount} repostCount={item.post.repostCount} - upvoteCount={item.post.upvoteCount} - isReposted={!!item.post.viewer.repost} - isUpvoted={!!item.post.viewer.upvote} + likeCount={item.post.likeCount} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} - onPressToggleUpvote={onPressToggleUpvote} + onPressToggleLike={onPressToggleLike} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} onDeletePost={onDeletePost} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index a6c66d143..6b3dc3ac6 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -15,7 +15,7 @@ import {PostThreadViewModel} from 'state/models/post-thread-view' import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' -import {PostEmbeds} from '../util/PostEmbeds' +import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/PostCtrls' import {PostMutedWrapper} from '../util/PostMuted' import {Text} from '../util/text/Text' @@ -118,10 +118,10 @@ export const Post = observer(function Post({ .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } - const onPressToggleUpvote = () => { + const onPressToggleLike = () => { return item - .toggleUpvote() - .catch(e => store.log.error('Failed to toggle upvote', e)) + .toggleLike() + .catch(e => store.log.error('Failed to toggle like', e)) } const onCopyPostText = () => { Clipboard.setString(record.text) @@ -166,7 +166,6 @@ export const Post = observer(function Post({ timestamp={item.post.indexedAt} postHref={itemHref} did={item.post.author.did} - declarationCid={item.post.author.declaration.cid} /> {replyAuthorDid !== '' && ( <View style={[s.flexRow, s.mb2, s.alignCenter]}> @@ -211,12 +210,12 @@ export const Post = observer(function Post({ isAuthor={item.post.author.did === store.me.did} replyCount={item.post.replyCount} repostCount={item.post.repostCount} - upvoteCount={item.post.upvoteCount} - isReposted={!!item.post.viewer.repost} - isUpvoted={!!item.post.viewer.upvote} + likeCount={item.post.likeCount} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} - onPressToggleUpvote={onPressToggleUpvote} + onPressToggleLike={onPressToggleLike} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} onDeletePost={onDeletePost} diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 4154cbe75..d07afca34 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -128,6 +128,7 @@ export const Feed = observer(function Feed({ <View testID={testID} style={style}> {data.length > 0 && ( <FlatList + testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} data={data} keyExtractor={item => item._reactKey} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 573b92fd3..734034a89 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -13,7 +13,7 @@ import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostCtrls} from '../util/PostCtrls' -import {PostEmbeds} from '../util/PostEmbeds' +import {PostEmbeds} from '../util/post-embeds' import {PostMutedWrapper} from '../util/PostMuted' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' @@ -79,11 +79,11 @@ export const FeedItem = observer(function ({ .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } - const onPressToggleUpvote = () => { + const onPressToggleLike = () => { track('FeedItem:PostLike') return item - .toggleUpvote() - .catch(e => store.log.error('Failed to toggle upvote', e)) + .toggleLike() + .catch(e => store.log.error('Failed to toggle like', e)) } const onCopyPostText = () => { Clipboard.setString(record?.text || '') @@ -127,7 +127,12 @@ export const FeedItem = observer(function ({ return ( <PostMutedWrapper isMuted={isMuted}> - <Link style={outerStyles} href={itemHref} title={itemTitle} noFeedback> + <Link + testID={`feedItem-by-${item.post.author.handle}`} + style={outerStyles} + href={itemHref} + title={itemTitle} + noFeedback> {isThreadChild && ( <View style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} @@ -189,7 +194,6 @@ export const FeedItem = observer(function ({ timestamp={item.post.indexedAt} postHref={itemHref} did={item.post.author.did} - declarationCid={item.post.author.declaration.cid} showFollowBtn={showFollowBtn} /> {!isThreadChild && replyAuthorDid !== '' && ( @@ -239,12 +243,12 @@ export const FeedItem = observer(function ({ isAuthor={item.post.author.did === store.me.did} replyCount={item.post.replyCount} repostCount={item.post.repostCount} - upvoteCount={item.post.upvoteCount} - isReposted={!!item.post.viewer.repost} - isUpvoted={!!item.post.viewer.upvote} + likeCount={item.post.likeCount} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} - onPressToggleUpvote={onPressToggleUpvote} + onPressToggleLike={onPressToggleLike} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} onDeletePost={onDeletePost} diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 5204f5a40..f22eb9b4a 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -2,19 +2,16 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {Button, ButtonType} from '../util/forms/Button' import {useStores} from 'state/index' -import * as apilib from 'lib/api/index' import * as Toast from '../util/Toast' const FollowButton = observer( ({ type = 'inverted', did, - declarationCid, onToggleFollow, }: { type?: ButtonType did: string - declarationCid: string onToggleFollow?: (v: boolean) => void }) => { const store = useStores() @@ -23,7 +20,7 @@ const FollowButton = observer( const onToggleFollowInner = async () => { if (store.me.follows.isFollowing(did)) { try { - await apilib.unfollow(store, store.me.follows.getFollowUri(did)) + await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) store.me.follows.removeFollow(did) onToggleFollow?.(false) } catch (e: any) { @@ -32,7 +29,7 @@ const FollowButton = observer( } } else { try { - const res = await apilib.follow(store, did, declarationCid) + const res = await store.agent.follow(did) store.me.follows.addFollow(did, res.uri) onToggleFollow?.(true) } catch (e: any) { diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 748648742..0beac8a7f 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,7 +1,7 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {AppBskyActorProfile} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' @@ -11,6 +11,7 @@ import {useStores} from 'state/index' import FollowButton from './FollowButton' export function ProfileCard({ + testID, handle, displayName, avatar, @@ -21,6 +22,7 @@ export function ProfileCard({ followers, renderButton, }: { + testID?: string handle: string displayName?: string avatar?: string @@ -28,12 +30,13 @@ export function ProfileCard({ isFollowedBy?: boolean noBg?: boolean noBorder?: boolean - followers?: AppBskyActorProfile.View[] | undefined + followers?: AppBskyActorDefs.ProfileView[] | undefined renderButton?: () => JSX.Element }) { const pal = usePalette('default') return ( <Link + testID={testID} style={[ styles.outer, pal.border, @@ -106,7 +109,6 @@ export function ProfileCard({ export const ProfileCardWithFollowBtn = observer( ({ did, - declarationCid, handle, displayName, avatar, @@ -117,7 +119,6 @@ export const ProfileCardWithFollowBtn = observer( followers, }: { did: string - declarationCid: string handle: string displayName?: string avatar?: string @@ -125,7 +126,7 @@ export const ProfileCardWithFollowBtn = observer( isFollowedBy?: boolean noBg?: boolean noBorder?: boolean - followers?: AppBskyActorProfile.View[] | undefined + followers?: AppBskyActorDefs.ProfileView[] | undefined }) => { const store = useStores() const isMe = store.me.handle === handle @@ -140,11 +141,7 @@ export const ProfileCardWithFollowBtn = observer( noBg={noBg} noBorder={noBorder} followers={followers} - renderButton={ - isMe - ? undefined - : () => <FollowButton did={did} declarationCid={declarationCid} /> - } + renderButton={isMe ? undefined : () => <FollowButton did={did} />} /> ) }, diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index d1488403a..8d489ad0a 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -19,7 +19,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({ const pal = usePalette('default') const store = useStores() const view = React.useMemo( - () => new UserFollowersViewModel(store, {user: name}), + () => new UserFollowersViewModel(store, {actor: name}), [store, name], ) @@ -64,7 +64,6 @@ export const ProfileFollowers = observer(function ProfileFollowers({ <ProfileCardWithFollowBtn key={item.did} did={item.did} - declarationCid={item.declaration.cid} handle={item.handle} displayName={item.displayName} avatar={item.avatar} diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index ddb64787a..849b33441 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -16,7 +16,7 @@ export const ProfileFollows = observer(function ProfileFollows({ const pal = usePalette('default') const store = useStores() const view = React.useMemo( - () => new UserFollowsViewModel(store, {user: name}), + () => new UserFollowsViewModel(store, {actor: name}), [store, name], ) @@ -61,7 +61,6 @@ export const ProfileFollows = observer(function ProfileFollows({ <ProfileCardWithFollowBtn key={item.did} did={item.did} - declarationCid={item.declaration.cid} handle={item.handle} displayName={item.displayName} avatar={item.avatar} diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 06dd20989..6294c627b 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -33,7 +33,61 @@ import {isDesktopWeb} from 'platform/detection' const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30} -export const ProfileHeader = observer(function ProfileHeader({ +export const ProfileHeader = observer( + ({ + view, + onRefreshAll, + }: { + view: ProfileViewModel + onRefreshAll: () => void + }) => { + const pal = usePalette('default') + + // loading + // = + if (!view || !view.hasLoaded) { + return ( + <View style={pal.view}> + <LoadingPlaceholder width="100%" height={120} /> + <View + style={[ + pal.view, + {borderColor: pal.colors.background}, + styles.avi, + ]}> + <LoadingPlaceholder width={80} height={80} style={styles.br40} /> + </View> + <View style={styles.content}> + <View style={[styles.buttonsLine]}> + <LoadingPlaceholder width={100} height={31} style={styles.br50} /> + </View> + <View style={styles.displayNameLine}> + <Text type="title-2xl" style={[pal.text, styles.title]}> + {view.displayName || view.handle} + </Text> + </View> + </View> + </View> + ) + } + + // error + // = + if (view.hasError) { + return ( + <View testID="profileHeaderHasError"> + <Text>{view.error}</Text> + </View> + ) + } + + // loaded + // = + return <ProfileHeaderLoaded view={view} onRefreshAll={onRefreshAll} /> + }, +) + +const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({ view, onRefreshAll, }: { @@ -44,14 +98,17 @@ export const ProfileHeader = observer(function ProfileHeader({ const store = useStores() const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() + const onPressBack = React.useCallback(() => { navigation.goBack() }, [navigation]) + const onPressAvi = React.useCallback(() => { if (view.avatar) { store.shell.openLightbox(new ProfileImageLightbox(view)) } }, [store, view]) + const onPressToggleFollow = React.useCallback(() => { view?.toggleFollowing().then( () => { @@ -64,6 +121,7 @@ export const ProfileHeader = observer(function ProfileHeader({ err => store.log.error('Failed to toggle follow', err), ) }, [view, store]) + const onPressEditProfile = React.useCallback(() => { track('ProfileHeader:EditProfileButtonClicked') store.shell.openModal({ @@ -72,18 +130,22 @@ export const ProfileHeader = observer(function ProfileHeader({ onUpdate: onRefreshAll, }) }, [track, store, view, onRefreshAll]) + const onPressFollowers = React.useCallback(() => { track('ProfileHeader:FollowersButtonClicked') navigation.push('ProfileFollowers', {name: view.handle}) }, [track, navigation, view]) + const onPressFollows = React.useCallback(() => { track('ProfileHeader:FollowsButtonClicked') navigation.push('ProfileFollows', {name: view.handle}) }, [track, navigation, view]) + const onPressShare = React.useCallback(() => { track('ProfileHeader:ShareButtonClicked') Share.share({url: toShareUrl(`/profile/${view.handle}`)}) }, [track, view]) + const onPressMuteAccount = React.useCallback(async () => { track('ProfileHeader:MuteAccountButtonClicked') try { @@ -94,6 +156,7 @@ export const ProfileHeader = observer(function ProfileHeader({ Toast.show(`There was an issue! ${e.toString()}`) } }, [track, view, store]) + const onPressUnmuteAccount = React.useCallback(async () => { track('ProfileHeader:UnmuteAccountButtonClicked') try { @@ -104,6 +167,7 @@ export const ProfileHeader = observer(function ProfileHeader({ Toast.show(`There was an issue! ${e.toString()}`) } }, [track, view, store]) + const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') store.shell.openModal({ @@ -112,54 +176,39 @@ export const ProfileHeader = observer(function ProfileHeader({ }) }, [track, store, view]) - // loading - // = - if (!view || !view.hasLoaded) { - return ( - <View style={pal.view}> - <LoadingPlaceholder width="100%" height={120} /> - <View - style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> - <LoadingPlaceholder width={80} height={80} style={styles.br40} /> - </View> - <View style={styles.content}> - <View style={[styles.buttonsLine]}> - <LoadingPlaceholder width={100} height={31} style={styles.br50} /> - </View> - <View style={styles.displayNameLine}> - <Text type="title-2xl" style={[pal.text, styles.title]}> - {view.displayName || view.handle} - </Text> - </View> - </View> - </View> - ) - } - - // error - // = - if (view.hasError) { - return ( - <View testID="profileHeaderHasError"> - <Text>{view.error}</Text> - </View> - ) - } - - // loaded - // = - const isMe = store.me.did === view.did - let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}] - if (!isMe) { - dropdownItems.push({ - label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', - onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount, - }) - dropdownItems.push({ - label: 'Report Account', - onPress: onPressReportAccount, - }) - } + const isMe = React.useMemo( + () => store.me.did === view.did, + [store.me.did, view.did], + ) + const dropdownItems: DropdownItem[] = React.useMemo(() => { + let items: DropdownItem[] = [ + { + testID: 'profileHeaderDropdownSahreBtn', + label: 'Share', + onPress: onPressShare, + }, + ] + if (!isMe) { + items.push({ + testID: 'profileHeaderDropdownMuteBtn', + label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', + onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount, + }) + items.push({ + testID: 'profileHeaderDropdownReportBtn', + label: 'Report Account', + onPress: onPressReportAccount, + }) + } + return items + }, [ + isMe, + view.viewer.muted, + onPressShare, + onPressUnmuteAccount, + onPressMuteAccount, + onPressReportAccount, + ]) return ( <View style={pal.view}> <UserBanner banner={view.banner} /> @@ -178,6 +227,7 @@ export const ProfileHeader = observer(function ProfileHeader({ <> {store.me.follows.isFollowing(view.did) ? ( <TouchableOpacity + testID="unfollowBtn" onPress={onPressToggleFollow} style={[styles.btn, styles.mainBtn, pal.btn]}> <FontAwesomeIcon @@ -191,7 +241,7 @@ export const ProfileHeader = observer(function ProfileHeader({ </TouchableOpacity> ) : ( <TouchableOpacity - testID="profileHeaderToggleFollowButton" + testID="followBtn" onPress={onPressToggleFollow} style={[styles.btn, styles.primaryBtn]}> <FontAwesomeIcon @@ -207,6 +257,7 @@ export const ProfileHeader = observer(function ProfileHeader({ )} {dropdownItems?.length ? ( <DropdownButton + testID="profileHeaderDropdownBtn" type="bare" items={dropdownItems} style={[styles.btn, styles.secondaryBtn, pal.btn]}> @@ -215,7 +266,10 @@ export const ProfileHeader = observer(function ProfileHeader({ ) : undefined} </View> <View style={styles.displayNameLine}> - <Text type="title-2xl" style={[pal.text, styles.title]}> + <Text + testID="profileHeaderDisplayName" + type="title-2xl" + style={[pal.text, styles.title]}> {view.displayName || view.handle} </Text> </View> @@ -241,19 +295,17 @@ export const ProfileHeader = observer(function ProfileHeader({ {pluralize(view.followersCount, 'follower')} </Text> </TouchableOpacity> - {view.isUser ? ( - <TouchableOpacity - testID="profileHeaderFollowsButton" - style={[s.flexRow, s.mr10]} - onPress={onPressFollows}> - <Text type="md" style={[s.bold, s.mr2, pal.text]}> - {view.followsCount} - </Text> - <Text type="md" style={[pal.textLight]}> - following - </Text> - </TouchableOpacity> - ) : undefined} + <TouchableOpacity + testID="profileHeaderFollowsButton" + style={[s.flexRow, s.mr10]} + onPress={onPressFollows}> + <Text type="md" style={[s.bold, s.mr2, pal.text]}> + {view.followsCount} + </Text> + <Text type="md" style={[pal.textLight]}> + following + </Text> + </TouchableOpacity> <View style={[s.flexRow, s.mr10]}> <Text type="md" style={[s.bold, s.mr2, pal.text]}> {view.postsCount} @@ -265,13 +317,16 @@ export const ProfileHeader = observer(function ProfileHeader({ </View> {view.descriptionRichText ? ( <RichText + testID="profileHeaderDescription" style={[styles.description, pal.text]} numberOfLines={15} richText={view.descriptionRichText} /> ) : undefined} {view.viewer.muted ? ( - <View style={[styles.detailLine, pal.btn, s.p5]}> + <View + testID="profileHeaderMutedNotice" + style={[styles.detailLine, pal.btn, s.p5]}> <FontAwesomeIcon icon={['far', 'eye-slash']} style={[pal.text, s.mr5]} diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx index 4bf46515c..b53965f44 100644 --- a/src/view/com/search/SearchResults.tsx +++ b/src/view/com/search/SearchResults.tsx @@ -97,7 +97,6 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => { <ProfileCardWithFollowBtn key={item.did} did={item.did} - declarationCid={item.declaration.cid} handle={item.handle} displayName={item.displayName} avatar={item.avatar} diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index f356f0b09..703869be1 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -29,6 +29,7 @@ type Event = | GestureResponderEvent export const Link = observer(function Link({ + testID, style, href, title, @@ -36,6 +37,7 @@ export const Link = observer(function Link({ noFeedback, asAnchor, }: { + testID?: string style?: StyleProp<ViewStyle> href?: string title?: string @@ -58,6 +60,7 @@ export const Link = observer(function Link({ if (noFeedback) { return ( <TouchableWithoutFeedback + testID={testID} onPress={onPress} // @ts-ignore web only -prf href={asAnchor ? href : undefined}> @@ -69,6 +72,7 @@ export const Link = observer(function Link({ } return ( <TouchableOpacity + testID={testID} style={style} onPress={onPress} // @ts-ignore web only -prf @@ -79,6 +83,7 @@ export const Link = observer(function Link({ }) export const TextLink = observer(function TextLink({ + testID, type = 'md', style, href, @@ -86,6 +91,7 @@ export const TextLink = observer(function TextLink({ numberOfLines, lineHeight, }: { + testID?: string type?: TypographyVariant style?: StyleProp<TextStyle> href: string @@ -106,6 +112,7 @@ export const TextLink = observer(function TextLink({ return ( <Text + testID={testID} type={type} style={style} numberOfLines={numberOfLines} @@ -120,6 +127,7 @@ export const TextLink = observer(function TextLink({ * Only acts as a link on desktop web */ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ + testID, type = 'md', style, href, @@ -127,6 +135,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ numberOfLines, lineHeight, }: { + testID?: string type?: TypographyVariant style?: StyleProp<TextStyle> href: string @@ -137,6 +146,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ if (isDesktopWeb) { return ( <TextLink + testID={testID} type={type} style={style} href={href} @@ -148,6 +158,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ } return ( <Text + testID={testID} type={type} style={style} numberOfLines={numberOfLines} diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index 00e35eef7..6904928f4 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -45,12 +45,12 @@ interface PostCtrlsOpts { style?: StyleProp<ViewStyle> replyCount?: number repostCount?: number - upvoteCount?: number + likeCount?: number isReposted: boolean - isUpvoted: boolean + isLiked: boolean onPressReply: () => void onPressToggleRepost: () => Promise<void> - onPressToggleUpvote: () => Promise<void> + onPressToggleLike: () => Promise<void> onCopyPostText: () => void onOpenTranslate: () => void onDeletePost: () => void @@ -157,26 +157,26 @@ export function PostCtrls(opts: PostCtrlsOpts) { }) } - const onPressToggleUpvoteWrapper = () => { - if (!opts.isUpvoted) { + const onPressToggleLikeWrapper = () => { + if (!opts.isLiked) { ReactNativeHapticFeedback.trigger('impactMedium') setLikeMod(1) opts - .onPressToggleUpvote() + .onPressToggleLike() .catch(_e => undefined) .then(() => setLikeMod(0)) // DISABLED see #135 // likeRef.current?.trigger( // {start: ctrlAnimStart, style: ctrlAnimStyle}, // async () => { - // await opts.onPressToggleUpvote().catch(_e => undefined) + // await opts.onPressToggleLike().catch(_e => undefined) // setLikeMod(0) // }, // ) } else { setLikeMod(-1) opts - .onPressToggleUpvote() + .onPressToggleLike() .catch(_e => undefined) .then(() => setLikeMod(0)) } @@ -186,6 +186,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { <View style={[styles.ctrls, opts.style]}> <View style={s.flex1}> <TouchableOpacity + testID="replyBtn" style={styles.ctrl} hitSlop={HITSLOP} onPress={opts.onPressReply}> @@ -203,6 +204,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { </View> <View style={s.flex1}> <TouchableOpacity + testID="repostBtn" hitSlop={HITSLOP} onPress={onPressToggleRepostWrapper} style={styles.ctrl}> @@ -230,6 +232,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { } {typeof opts.repostCount !== 'undefined' ? ( <Text + testID="repostCount" style={ opts.isReposted || repostMod > 0 ? [s.bold, s.green3, s.f15, s.ml5] @@ -242,12 +245,13 @@ export function PostCtrls(opts: PostCtrlsOpts) { </View> <View style={s.flex1}> <TouchableOpacity + testID="likeBtn" style={styles.ctrl} hitSlop={HITSLOP} - onPress={onPressToggleUpvoteWrapper}> - {opts.isUpvoted || likeMod > 0 ? ( + onPress={onPressToggleLikeWrapper}> + {opts.isLiked || likeMod > 0 ? ( <HeartIconSolid - style={styles.ctrlIconUpvoted as StyleProp<ViewStyle>} + style={styles.ctrlIconLiked as StyleProp<ViewStyle>} size={opts.big ? 22 : 16} /> ) : ( @@ -259,9 +263,9 @@ export function PostCtrls(opts: PostCtrlsOpts) { )} { undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}> - {opts.isUpvoted || likeMod > 0 ? ( + {opts.isLiked || likeMod > 0 ? ( <HeartIconSolid - style={styles.ctrlIconUpvoted as ViewStyle} + style={styles.ctrlIconLiked as ViewStyle} size={opts.big ? 22 : 16} /> ) : ( @@ -276,14 +280,15 @@ export function PostCtrls(opts: PostCtrlsOpts) { )} </TriggerableAnimated>*/ } - {typeof opts.upvoteCount !== 'undefined' ? ( + {typeof opts.likeCount !== 'undefined' ? ( <Text + testID="likeCount" style={ - opts.isUpvoted || likeMod > 0 + opts.isLiked || likeMod > 0 ? [s.bold, s.red3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.upvoteCount + likeMod} + {opts.likeCount + likeMod} </Text> ) : undefined} </TouchableOpacity> @@ -291,6 +296,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { <View style={s.flex1}> {opts.big ? undefined : ( <PostDropdownBtn + testID="postDropdownBtn" style={styles.ctrl} itemUri={opts.itemUri} itemCid={opts.itemCid} @@ -330,7 +336,7 @@ const styles = StyleSheet.create({ ctrlIconReposted: { color: colors.green3, }, - ctrlIconUpvoted: { + ctrlIconLiked: { color: colors.red3, }, mt1: { diff --git a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx deleted file mode 100644 index d9425fe4e..000000000 --- a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, {useEffect} from 'react' -import {useState} from 'react' -import { - View, - StyleSheet, - Pressable, - TouchableWithoutFeedback, - EmitterSubscription, -} from 'react-native' -import YoutubePlayer from 'react-native-youtube-iframe' -import {usePalette} from 'lib/hooks/usePalette' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import ExternalLinkEmbed from './ExternalLinkEmbed' -import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' -import {useStores} from 'state/index' - -const YoutubeEmbed = ({ - link, - videoId, -}: { - videoId: string - link: PresentedExternal -}) => { - const store = useStores() - const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false) - const [playerDimensions, setPlayerDimensions] = useState({ - width: 0, - height: 0, - }) - const pal = usePalette('default') - const handlePlayButtonPressed = () => { - setDisplayVideoPlayer(true) - } - const handleOnLayout = (event: { - nativeEvent: {layout: {width: any; height: any}} - }) => { - setPlayerDimensions({ - width: event.nativeEvent.layout.width, - height: event.nativeEvent.layout.height, - }) - } - useEffect(() => { - let sub: EmitterSubscription - if (displayVideoPlayer) { - sub = store.onNavigation(() => { - setDisplayVideoPlayer(false) - }) - } - return () => sub && sub.remove() - }, [displayVideoPlayer, store]) - - const imageChild = ( - <Pressable onPress={handlePlayButtonPressed} style={styles.playButton}> - <FontAwesomeIcon icon="play" size={24} color="white" /> - </Pressable> - ) - - if (!displayVideoPlayer) { - return ( - <View - style={[styles.extOuter, pal.view, pal.border]} - onLayout={handleOnLayout}> - <ExternalLinkEmbed - link={link} - onImagePress={handlePlayButtonPressed} - imageChild={imageChild} - /> - </View> - ) - } - - const height = (playerDimensions.width / 16) * 9 - const noop = () => {} - - return ( - <TouchableWithoutFeedback onPress={noop}> - <View> - {/* Removing the outter View will make tap events propagate to parents */} - <YoutubePlayer - initialPlayerParams={{ - modestbranding: true, - }} - webViewProps={{ - startInLoadingState: true, - }} - height={height} - videoId={videoId} - webViewStyle={styles.webView} - /> - </View> - </TouchableWithoutFeedback> - ) -} - -const styles = StyleSheet.create({ - extOuter: { - borderWidth: 1, - borderRadius: 8, - marginTop: 4, - }, - playButton: { - position: 'absolute', - alignSelf: 'center', - alignItems: 'center', - top: '44%', - justifyContent: 'center', - backgroundColor: 'black', - padding: 10, - borderRadius: 50, - opacity: 0.8, - }, - webView: { - alignItems: 'center', - alignContent: 'center', - justifyContent: 'center', - }, -}) - -export default YoutubeEmbed diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c53de5c1f..a675283b8 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -16,7 +16,6 @@ interface PostMetaOpts { postHref: string timestamp: string did?: string - declarationCid?: string showFollowBtn?: boolean } @@ -34,13 +33,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { setDidFollow(true) }, [setDidFollow]) - if ( - opts.showFollowBtn && - !isMe && - (!isFollowing || didFollow) && - opts.did && - opts.declarationCid - ) { + if (opts.showFollowBtn && !isMe && (!isFollowing || didFollow) && opts.did) { // two-liner with follow button return ( <View style={styles.metaTwoLine}> @@ -79,7 +72,6 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { <FollowButton type="default" did={opts.did} - declarationCid={opts.declarationCid} onToggleFollow={onToggleFollow} /> </View> diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 2e0632521..ff741cd34 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -23,6 +23,7 @@ import {isWeb} from 'platform/detection' function DefaultAvatar({size}: {size: number}) { return ( <Svg + testID="userAvatarFallback" width={size} height={size} viewBox="0 0 24 24" @@ -56,6 +57,7 @@ export function UserAvatar({ const dropdownItems = [ !isWeb && { + testID: 'changeAvatarCameraBtn', label: 'Camera', icon: 'camera' as IconProp, onPress: async () => { @@ -73,6 +75,7 @@ export function UserAvatar({ }, }, { + testID: 'changeAvatarLibraryBtn', label: 'Library', icon: 'image' as IconProp, onPress: async () => { @@ -94,6 +97,7 @@ export function UserAvatar({ }, }, { + testID: 'changeAvatarRemoveBtn', label: 'Remove', icon: ['far', 'trash-can'] as IconProp, onPress: async () => { @@ -104,6 +108,7 @@ export function UserAvatar({ // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( <DropdownButton + testID="changeAvatarBtn" type="bare" items={dropdownItems} openToRight @@ -112,6 +117,7 @@ export function UserAvatar({ menuWidth={170}> {avatar ? ( <HighPriorityImage + testID="userAvatarImage" style={{ width: size, height: size, @@ -132,6 +138,7 @@ export function UserAvatar({ </DropdownButton> ) : avatar ? ( <HighPriorityImage + testID="userAvatarImage" style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} resizeMode="stretch" source={{uri: avatar}} diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 8317f93ac..56d7e370a 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -33,6 +33,7 @@ export function UserBanner({ const dropdownItems = [ !isWeb && { + testID: 'changeBannerCameraBtn', label: 'Camera', icon: 'camera' as IconProp, onPress: async () => { @@ -51,6 +52,7 @@ export function UserBanner({ }, }, { + testID: 'changeBannerLibraryBtn', label: 'Library', icon: 'image' as IconProp, onPress: async () => { @@ -73,6 +75,7 @@ export function UserBanner({ }, }, { + testID: 'changeBannerRemoveBtn', label: 'Remove', icon: ['far', 'trash-can'] as IconProp, onPress: () => { @@ -84,6 +87,7 @@ export function UserBanner({ // setUserBanner is only passed as prop on the EditProfile component return onSelectNewBanner ? ( <DropdownButton + testID="changeBannerBtn" type="bare" items={dropdownItems} openToRight @@ -91,9 +95,16 @@ export function UserBanner({ bottomOffset={-10} menuWidth={170}> {banner ? ( - <Image style={styles.bannerImage} source={{uri: banner}} /> + <Image + testID="userBannerImage" + style={styles.bannerImage} + source={{uri: banner}} + /> ) : ( - <View style={[styles.bannerImage, styles.defaultBanner]} /> + <View + testID="userBannerFallback" + style={[styles.bannerImage, styles.defaultBanner]} + /> )} <View style={[styles.editButtonContainer, pal.btn]}> <FontAwesomeIcon @@ -106,12 +117,16 @@ export function UserBanner({ </DropdownButton> ) : banner ? ( <Image + testID="userBannerImage" style={styles.bannerImage} resizeMode="cover" source={{uri: banner}} /> ) : ( - <View style={[styles.bannerImage, styles.defaultBanner]} /> + <View + testID="userBannerFallback" + style={[styles.bannerImage, styles.defaultBanner]} + /> ) } diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index a99282512..ad0a5a1d2 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -51,7 +51,7 @@ export const ViewHeader = observer(function ({ return ( <Container hideOnScroll={hideOnScroll || false}> <TouchableOpacity - testID="viewHeaderBackOrMenuBtn" + testID="viewHeaderDrawerBtn" onPress={canGoBack ? onPressBack : onPressMenu} hitSlop={BACK_HITSLOP} style={canGoBack ? styles.backBtn : styles.backBtnWide}> diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index e1280fd82..82351cf08 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -47,13 +47,18 @@ export function ViewSelector({ // events // = - const onSwipeEnd = (dx: number) => { - if (dx !== 0) { - setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length)) - } - } - const onPressSelection = (index: number) => - setSelectedIndex(clamp(index, 0, sections.length)) + const onSwipeEnd = React.useCallback( + (dx: number) => { + if (dx !== 0) { + setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length)) + } + }, + [setSelectedIndex, selectedIndex, sections], + ) + const onPressSelection = React.useCallback( + (index: number) => setSelectedIndex(clamp(index, 0, sections.length)), + [setSelectedIndex, sections], + ) useEffect(() => { onSelectView?.(selectedIndex) }, [selectedIndex, onSelectView]) @@ -61,27 +66,33 @@ export function ViewSelector({ // rendering // = - const renderItemInternal = ({item}: {item: any}) => { - if (item === HEADER_ITEM) { - if (renderHeader) { - return renderHeader() + const renderItemInternal = React.useCallback( + ({item}: {item: any}) => { + if (item === HEADER_ITEM) { + if (renderHeader) { + return renderHeader() + } + return <View /> + } else if (item === SELECTOR_ITEM) { + return ( + <Selector + items={sections} + panX={panX} + selectedIndex={selectedIndex} + onSelect={onPressSelection} + /> + ) + } else { + return renderItem(item) } - return <View /> - } else if (item === SELECTOR_ITEM) { - return ( - <Selector - items={sections} - panX={panX} - selectedIndex={selectedIndex} - onSelect={onPressSelection} - /> - ) - } else { - return renderItem(item) - } - } + }, + [sections, panX, selectedIndex, onPressSelection, renderHeader, renderItem], + ) - const data = [HEADER_ITEM, SELECTOR_ITEM, ...items] + const data = React.useMemo( + () => [HEADER_ITEM, SELECTOR_ITEM, ...items], + [items], + ) return ( <HorzSwipe hasPriority diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index f3f4d1c79..b7c058d2d 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -27,11 +27,13 @@ export function Button({ style, onPress, children, + testID, }: React.PropsWithChildren<{ type?: ButtonType label?: string style?: StyleProp<ViewStyle> onPress?: () => void + testID?: string }>) { const theme = useTheme() const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, { @@ -107,7 +109,8 @@ export function Button({ return ( <TouchableOpacity style={[outerStyle, styles.outer, style]} - onPress={onPress}> + onPress={onPress} + testID={testID}> {label ? ( <Text type="button" style={[labelStyle]}> {label} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index d6ae800c6..938c346cd 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -24,6 +24,7 @@ const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} const ESTIMATED_MENU_ITEM_HEIGHT = 52 export interface DropdownItem { + testID?: string icon?: IconProp label: string onPress: () => void @@ -33,6 +34,7 @@ type MaybeDropdownItem = DropdownItem | false | undefined export type DropdownButtonType = ButtonType | 'bare' export function DropdownButton({ + testID, type = 'bare', style, items, @@ -43,6 +45,7 @@ export function DropdownButton({ rightOffset = 0, bottomOffset = 0, }: { + testID?: string type?: DropdownButtonType style?: StyleProp<ViewStyle> items: MaybeDropdownItem[] @@ -90,22 +93,18 @@ export function DropdownButton({ if (type === 'bare') { return ( <TouchableOpacity + testID={testID} style={style} onPress={onPress} hitSlop={HITSLOP} - // Fix an issue where specific references cause runtime error in jest environment - ref={ - typeof process !== 'undefined' && process.env.JEST_WORKER_ID != null - ? null - : ref - }> + ref={ref}> {children} </TouchableOpacity> ) } return ( <View ref={ref}> - <Button onPress={onPress} style={style} label={label}> + <Button testID={testID} onPress={onPress} style={style} label={label}> {children} </Button> </View> @@ -113,6 +112,7 @@ export function DropdownButton({ } export function PostDropdownBtn({ + testID, style, children, itemUri, @@ -123,6 +123,7 @@ export function PostDropdownBtn({ onOpenTranslate, onDeletePost, }: { + testID?: string style?: StyleProp<ViewStyle> children?: React.ReactNode itemUri: string @@ -138,6 +139,7 @@ export function PostDropdownBtn({ const dropdownItems: DropdownItem[] = [ { + testID: 'postDropdownTranslateBtn', icon: 'language', label: 'Translate...', onPress() { @@ -145,6 +147,7 @@ export function PostDropdownBtn({ }, }, { + testID: 'postDropdownCopyTextBtn', icon: ['far', 'paste'], label: 'Copy post text', onPress() { @@ -152,6 +155,7 @@ export function PostDropdownBtn({ }, }, { + testID: 'postDropdownShareBtn', icon: 'share', label: 'Share...', onPress() { @@ -159,6 +163,7 @@ export function PostDropdownBtn({ }, }, { + testID: 'postDropdownReportBtn', icon: 'circle-exclamation', label: 'Report post', onPress() { @@ -171,6 +176,7 @@ export function PostDropdownBtn({ }, isAuthor ? { + testID: 'postDropdownDeleteBtn', icon: ['far', 'trash-can'], label: 'Delete post', onPress() { @@ -186,7 +192,11 @@ export function PostDropdownBtn({ ].filter(Boolean) as DropdownItem[] return ( - <DropdownButton style={style} items={dropdownItems} menuWidth={200}> + <DropdownButton + testID={testID} + style={style} + items={dropdownItems} + menuWidth={200}> {children} </DropdownButton> ) @@ -291,6 +301,7 @@ const DropdownItems = ({ ]}> {items.map((item, index) => ( <TouchableOpacity + testID={item.testID} key={index} style={[styles.menuItem]} onPress={() => onPressItem(index)}> diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index d6b2bb119..f5696a76d 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -6,12 +6,14 @@ import {useTheme} from 'lib/ThemeContext' import {choose} from 'lib/functions' export function RadioButton({ + testID, type = 'default-light', label, isSelected, style, onPress, }: { + testID?: string type?: ButtonType label: string isSelected: boolean @@ -119,7 +121,7 @@ export function RadioButton({ }, }) return ( - <Button type={type} onPress={onPress} style={style}> + <Button testID={testID} type={type} onPress={onPress} style={style}> <View style={styles.outer}> <View style={[circleStyle, styles.circle]}> {isSelected ? ( diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx index 901b0cdd8..071540b73 100644 --- a/src/view/com/util/forms/RadioGroup.tsx +++ b/src/view/com/util/forms/RadioGroup.tsx @@ -10,11 +10,13 @@ export interface RadioGroupItem { } export function RadioGroup({ + testID, type, items, initialSelection = '', onSelect, }: { + testID?: string type?: ButtonType items: RadioGroupItem[] initialSelection?: string @@ -30,6 +32,7 @@ export function RadioGroup({ {items.map((item, i) => ( <RadioButton key={item.key} + testID={testID ? `${testID}-${item.key}` : undefined} style={i !== 0 ? s.mt2 : undefined} type={type} label={item.label} diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 24dbe6a52..ddb09ce39 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -4,9 +4,9 @@ import { StyleProp, StyleSheet, TouchableOpacity, + View, ViewStyle, } from 'react-native' -// import Image from 'view/com/util/images/Image' import {clamp} from 'lib/numbers' import {useStores} from 'state/index' import {Dim} from 'lib/media/manip' @@ -51,16 +51,24 @@ export function AutoSizedImage({ }) }, [dim, setDim, setAspectRatio, store, uri]) + if (onPress || onLongPress || onPressIn) { + return ( + <TouchableOpacity + onPress={onPress} + onLongPress={onLongPress} + onPressIn={onPressIn} + delayPressIn={DELAY_PRESS_IN} + style={[styles.container, style]}> + <Image style={[styles.image, {aspectRatio}]} source={{uri}} /> + {children} + </TouchableOpacity> + ) + } return ( - <TouchableOpacity - onPress={onPress} - onLongPress={onLongPress} - onPressIn={onPressIn} - delayPressIn={DELAY_PRESS_IN} - style={[styles.container, style]}> + <View style={[styles.container, style]}> <Image style={[styles.image, {aspectRatio}]} source={{uri}} /> {children} - </TouchableOpacity> + </View> ) } diff --git a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index e8c63bdb7..a4cbb3e29 100644 --- a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -3,25 +3,20 @@ import {Text} from '../text/Text' import {AutoSizedImage} from '../images/AutoSizedImage' import {StyleSheet, View} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' -import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' +import {AppBskyEmbedExternal} from '@atproto/api' -const ExternalLinkEmbed = ({ +export const ExternalLinkEmbed = ({ link, - onImagePress, imageChild, }: { - link: PresentedExternal - onImagePress?: () => void + link: AppBskyEmbedExternal.ViewExternal imageChild?: React.ReactNode }) => { const pal = usePalette('default') return ( <> {link.thumb ? ( - <AutoSizedImage - uri={link.thumb} - style={styles.extImage} - onPress={onImagePress}> + <AutoSizedImage uri={link.thumb} style={styles.extImage}> {imageChild} </AutoSizedImage> ) : undefined} @@ -65,5 +60,3 @@ const styles = StyleSheet.create({ marginTop: 4, }, }) - -export default ExternalLinkEmbed diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index fee67c9bc..9dc5739a0 100644 --- a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -1,13 +1,21 @@ -import {StyleSheet} from 'react-native' import React from 'react' +import {StyleProp, StyleSheet, ViewStyle} from 'react-native' +import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api' import {AtUri} from '../../../../third-party/uri' import {PostMeta} from '../PostMeta' import {Link} from '../Link' import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' import {ComposerOptsQuote} from 'state/models/ui/shell' +import {PostEmbeds} from '.' -const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { +export function QuoteEmbed({ + quote, + style, +}: { + quote: ComposerOptsQuote + style?: StyleProp<ViewStyle> +}) { const pal = usePalette('default') const itemUrip = new AtUri(quote.uri) const itemHref = `/profile/${quote.author.handle}/post/${itemUrip.rkey}` @@ -16,9 +24,18 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { () => quote.text.trim().length === 0, [quote.text], ) + const imagesEmbed = React.useMemo( + () => + quote.embeds?.find( + embed => + AppBskyEmbedImages.isView(embed) || + AppBskyEmbedRecordWithMedia.isView(embed), + ), + [quote.embeds], + ) return ( <Link - style={[styles.container, pal.border]} + style={[styles.container, pal.border, style]} href={itemHref} title={itemTitle}> <PostMeta @@ -37,6 +54,12 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { quote.text )} </Text> + {AppBskyEmbedImages.isView(imagesEmbed) && ( + <PostEmbeds embed={imagesEmbed} /> + )} + {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && ( + <PostEmbeds embed={imagesEmbed.media} /> + )} </Link> ) } @@ -48,7 +71,6 @@ const styles = StyleSheet.create({ borderRadius: 8, paddingVertical: 8, paddingHorizontal: 12, - marginVertical: 8, borderWidth: 1, }, quotePost: { diff --git a/src/view/com/util/post-embeds/YoutubeEmbed.tsx b/src/view/com/util/post-embeds/YoutubeEmbed.tsx new file mode 100644 index 000000000..2ca0750a3 --- /dev/null +++ b/src/view/com/util/post-embeds/YoutubeEmbed.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {usePalette} from 'lib/hooks/usePalette' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {ExternalLinkEmbed} from './ExternalLinkEmbed' +import {AppBskyEmbedExternal} from '@atproto/api' +import {Link} from '../Link' + +export const YoutubeEmbed = ({ + link, + style, +}: { + link: AppBskyEmbedExternal.ViewExternal + style?: StyleProp<ViewStyle> +}) => { + const pal = usePalette('default') + + const imageChild = ( + <View style={styles.playButton}> + <FontAwesomeIcon icon="play" size={24} color="white" /> + </View> + ) + + return ( + <Link + style={[styles.extOuter, pal.view, pal.border, style]} + href={link.uri} + noFeedback> + <ExternalLinkEmbed link={link} imageChild={imageChild} /> + </Link> + ) +} + +const styles = StyleSheet.create({ + extOuter: { + borderWidth: 1, + borderRadius: 8, + }, + playButton: { + position: 'absolute', + alignSelf: 'center', + alignItems: 'center', + top: '44%', + justifyContent: 'center', + backgroundColor: 'black', + padding: 10, + borderRadius: 50, + opacity: 0.8, + }, + webView: { + alignItems: 'center', + alignContent: 'center', + justifyContent: 'center', + }, +}) diff --git a/src/view/com/util/PostEmbeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 02a8aa90e..726bea6e7 100644 --- a/src/view/com/util/PostEmbeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -10,6 +10,7 @@ import { AppBskyEmbedImages, AppBskyEmbedExternal, AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, AppBskyFeedPost, } from '@atproto/api' import {Link} from '../Link' @@ -19,15 +20,16 @@ import {ImagesLightbox} from 'state/models/ui/shell' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {saveImageModal} from 'lib/media/manip' -import YoutubeEmbed from './YoutubeEmbed' -import ExternalLinkEmbed from './ExternalLinkEmbed' +import {YoutubeEmbed} from './YoutubeEmbed' +import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {getYoutubeVideoId} from 'lib/strings/url-helpers' import QuoteEmbed from './QuoteEmbed' type Embed = - | AppBskyEmbedRecord.Presented - | AppBskyEmbedImages.Presented - | AppBskyEmbedExternal.Presented + | AppBskyEmbedRecord.View + | AppBskyEmbedImages.View + | AppBskyEmbedExternal.View + | AppBskyEmbedRecordWithMedia.View | {$type: string; [k: string]: unknown} export function PostEmbeds({ @@ -39,11 +41,35 @@ export function PostEmbeds({ }) { const pal = usePalette('default') const store = useStores() - if (AppBskyEmbedRecord.isPresented(embed)) { + + if ( + AppBskyEmbedRecordWithMedia.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record.record) && + AppBskyFeedPost.isRecord(embed.record.record.value) && + AppBskyFeedPost.validateRecord(embed.record.record.value).success + ) { + return ( + <View style={[styles.stackContainer, style]}> + <PostEmbeds embed={embed.media} /> + <QuoteEmbed + quote={{ + author: embed.record.record.author, + cid: embed.record.record.cid, + uri: embed.record.record.uri, + indexedAt: embed.record.record.indexedAt, + text: embed.record.record.value.text, + embeds: embed.record.record.embeds, + }} + /> + </View> + ) + } + + if (AppBskyEmbedRecord.isView(embed)) { if ( - AppBskyEmbedRecord.isPresentedRecord(embed.record) && - AppBskyFeedPost.isRecord(embed.record.record) && - AppBskyFeedPost.validateRecord(embed.record.record).success + AppBskyEmbedRecord.isViewRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.value) && + AppBskyFeedPost.validateRecord(embed.record.value).success ) { return ( <QuoteEmbed @@ -51,14 +77,17 @@ export function PostEmbeds({ author: embed.record.author, cid: embed.record.cid, uri: embed.record.uri, - indexedAt: embed.record.record.createdAt, // TODO - text: embed.record.record.text, + indexedAt: embed.record.indexedAt, + text: embed.record.value.text, + embeds: embed.record.embeds, }} + style={style} /> ) } } - if (AppBskyEmbedImages.isPresented(embed)) { + + if (AppBskyEmbedImages.isView(embed)) { if (embed.images.length > 0) { const uris = embed.images.map(img => img.fullsize) const openLightbox = (index: number) => { @@ -129,12 +158,13 @@ export function PostEmbeds({ } } } - if (AppBskyEmbedExternal.isPresented(embed)) { + + if (AppBskyEmbedExternal.isView(embed)) { const link = embed.external const youtubeVideoId = getYoutubeVideoId(link.uri) if (youtubeVideoId) { - return <YoutubeEmbed videoId={youtubeVideoId} link={link} /> + return <YoutubeEmbed link={link} style={style} /> } return ( @@ -150,6 +180,9 @@ export function PostEmbeds({ } const styles = StyleSheet.create({ + stackContainer: { + gap: 6, + }, imagesContainer: { marginTop: 4, }, diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index d4cf19172..804db002a 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -1,20 +1,22 @@ import React from 'react' import {TextStyle, StyleProp} from 'react-native' +import {RichText as RichTextObj, AppBskyRichtextFacet} from '@atproto/api' import {TextLink} from '../Link' import {Text} from './Text' import {lh} from 'lib/styles' import {toShortUrl} from 'lib/strings/url-helpers' -import {RichText as RichTextObj, Entity} from 'lib/strings/rich-text' import {useTheme, TypographyVariant} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' export function RichText({ + testID, type = 'md', richText, lineHeight = 1.2, style, numberOfLines, }: { + testID?: string type?: TypographyVariant richText?: RichTextObj lineHeight?: number @@ -29,17 +31,24 @@ export function RichText({ return null } - const {text, entities} = richText - if (!entities?.length) { + const {text, facets} = richText + if (!facets?.length) { if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) { style = { fontSize: 26, lineHeight: 30, } - return <Text style={[style, pal.text]}>{text}</Text> + return ( + <Text testID={testID} style={[style, pal.text]}> + {text} + </Text> + ) } return ( - <Text type={type} style={[style, pal.text, lineHeightStyle]}> + <Text + testID={testID} + type={type} + style={[style, pal.text, lineHeightStyle]}> {text} </Text> ) @@ -49,40 +58,40 @@ export function RichText({ } else if (!Array.isArray(style)) { style = [style] } - entities.sort(sortByIndex) - const segments = Array.from(toSegments(text, entities)) + const els = [] let key = 0 - for (const segment of segments) { - if (typeof segment === 'string') { - els.push(segment) + for (const segment of richText.segments()) { + const link = segment.link + const mention = segment.mention + if (mention && AppBskyRichtextFacet.validateMention(mention).success) { + els.push( + <TextLink + key={key} + type={type} + text={segment.text} + href={`/profile/${mention.did}`} + style={[style, lineHeightStyle, pal.link]} + />, + ) + } else if (link && AppBskyRichtextFacet.validateLink(link).success) { + els.push( + <TextLink + key={key} + type={type} + text={toShortUrl(segment.text)} + href={link.uri} + style={[style, lineHeightStyle, pal.link]} + />, + ) } else { - if (segment.entity.type === 'mention') { - els.push( - <TextLink - key={key} - type={type} - text={segment.text} - href={`/profile/${segment.entity.value}`} - style={[style, lineHeightStyle, pal.link]} - />, - ) - } else if (segment.entity.type === 'link') { - els.push( - <TextLink - key={key} - type={type} - text={toShortUrl(segment.text)} - href={segment.entity.value} - style={[style, lineHeightStyle, pal.link]} - />, - ) - } + els.push(segment.text) } key++ } return ( <Text + testID={testID} type={type} style={[style, pal.text, lineHeightStyle]} numberOfLines={numberOfLines}> @@ -90,38 +99,3 @@ export function RichText({ </Text> ) } - -function sortByIndex(a: Entity, b: Entity) { - return a.index.start - b.index.start -} - -function* toSegments(text: string, entities: Entity[]) { - let cursor = 0 - let i = 0 - do { - let currEnt = entities[i] - if (cursor < currEnt.index.start) { - yield text.slice(cursor, currEnt.index.start) - } else if (cursor > currEnt.index.start) { - i++ - continue - } - if (currEnt.index.start < currEnt.index.end) { - let subtext = text.slice(currEnt.index.start, currEnt.index.end) - if (!subtext.trim()) { - // dont yield links to empty strings - yield subtext - } else { - yield { - entity: currEnt, - text: subtext, - } - } - } - cursor = currEnt.index.end - i++ - } while (i < entities.length) - if (cursor < text.length) { - yield text.slice(cursor, text.length) - } -} diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 4f2bc4c15..871aae9c7 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -33,6 +33,7 @@ export const HomeScreen = withAuthRequired((_opts: Props) => { useFocusEffect( React.useCallback(() => { + store.shell.setMinimalShellMode(false) store.shell.setIsDrawerSwipeDisabled(selectedPage > 0) return () => { store.shell.setIsDrawerSwipeDisabled(false) @@ -42,6 +43,7 @@ export const HomeScreen = withAuthRequired((_opts: Props) => { const onPageSelected = React.useCallback( (index: number) => { + store.shell.setMinimalShellMode(false) setSelectedPage(index) store.shell.setIsDrawerSwipeDisabled(index > 0) }, @@ -54,7 +56,13 @@ export const HomeScreen = withAuthRequired((_opts: Props) => { const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { - return <FeedsTabBar {...props} onPressSelected={onPressSelected} /> + return ( + <FeedsTabBar + {...props} + testID="homeScreenFeedTabs" + onPressSelected={onPressSelected} + /> + ) }, [onPressSelected], ) @@ -66,27 +74,36 @@ export const HomeScreen = withAuthRequired((_opts: Props) => { const initialPage = store.me.follows.isEmpty ? 1 : 0 return ( <Pager + testID="homeScreen" onPageSelected={onPageSelected} renderTabBar={renderTabBar} tabBarPosition="top" initialPage={initialPage}> <FeedPage key="1" + testID="followingFeedPage" isPageFocused={selectedPage === 0} feed={store.me.mainFeed} renderEmptyState={renderFollowingEmptyState} /> - <FeedPage key="2" isPageFocused={selectedPage === 1} feed={algoFeed} /> + <FeedPage + key="2" + testID="whatshotFeedPage" + isPageFocused={selectedPage === 1} + feed={algoFeed} + /> </Pager> ) }) const FeedPage = observer( ({ + testID, isPageFocused, feed, renderEmptyState, }: { + testID?: string feed: FeedModel isPageFocused: boolean renderEmptyState?: () => JSX.Element @@ -163,9 +180,9 @@ const FeedPage = observer( }, [feed, scrollToTop]) return ( - <View style={s.h100pct}> + <View testID={testID} style={s.h100pct}> <Feed - testID="homeFeed" + testID={testID ? `${testID}-feed` : undefined} key="default" feed={feed} scrollElRef={scrollElRef} diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index 6ab37f117..cb52da58b 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -1,16 +1,28 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {useNavigation, StackActions} from '@react-navigation/native' +import { + useNavigation, + StackActions, + useFocusEffect, +} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {Text} from '../com/util/text/Text' import {Button} from 'view/com/util/forms/Button' import {NavigationProp} from 'lib/routes/types' import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' import {s} from 'lib/styles' export const NotFoundScreen = () => { const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() + const store = useStores() + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + }, [store]), + ) const canGoBack = navigation.canGoBack() const onPressHome = React.useCallback(() => { diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 7da563843..e5521c7ac 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -72,6 +72,7 @@ export const NotificationsScreen = withAuthRequired( // = useFocusEffect( React.useCallback(() => { + store.shell.setMinimalShellMode(false) store.log.debug('NotificationsScreen: Updating feed') const softResetSub = store.onScreenSoftReset(scrollToTop) store.me.notifications.loadUnreadCount() @@ -86,7 +87,7 @@ export const NotificationsScreen = withAuthRequired( ) return ( - <View style={s.hContentRegion}> + <View testID="notificationsScreen" style={s.hContentRegion}> <ViewHeader title="Notifications" canGoBack={false} /> <Feed view={store.me.notifications} diff --git a/src/view/screens/PostUpvotedBy.tsx b/src/view/screens/PostLikedBy.tsx index 35b55f3c4..fb44f1f9b 100644 --- a/src/view/screens/PostUpvotedBy.tsx +++ b/src/view/screens/PostLikedBy.tsx @@ -4,12 +4,12 @@ import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' -import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy' +import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' import {useStores} from 'state/index' import {makeRecordUri} from 'lib/strings/url-helpers' -type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostUpvotedBy'> -export const PostUpvotedByScreen = withAuthRequired(({route}: Props) => { +type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'> +export const PostLikedByScreen = withAuthRequired(({route}: Props) => { const store = useStores() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) @@ -23,7 +23,7 @@ export const PostUpvotedByScreen = withAuthRequired(({route}: Props) => { return ( <View> <ViewHeader title="Liked by" /> - <PostLikedByComponent uri={uri} direction="up" /> + <PostLikedByComponent uri={uri} /> </View> ) }) diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index ad54126b6..9bfdcc95a 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -29,8 +29,8 @@ export const PostThreadScreen = withAuthRequired(({route}: Props) => { useFocusEffect( React.useCallback(() => { - const threadCleanup = view.registerListeners() store.shell.setMinimalShellMode(false) + const threadCleanup = view.registerListeners() if (!view.hasLoaded && !view.isLoading) { view.setup().catch(err => { store.log.error('Failed to fetch thread', err) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 65f1fef26..556578e77 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -42,6 +42,7 @@ export const ProfileScreen = withAuthRequired( useFocusEffect( React.useCallback(() => { let aborted = false + store.shell.setMinimalShellMode(false) const feedCleanup = uiState.feed.registerListeners() if (hasSetup) { uiState.update() @@ -57,7 +58,7 @@ export const ProfileScreen = withAuthRequired( aborted = true feedCleanup() } - }, [hasSetup, uiState]), + }, [hasSetup, uiState, store]), ) // events diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index 641d144ae..e6947013e 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -152,6 +152,7 @@ export const SearchScreen = withAuthRequired( {autocompleteView.searchRes.map(item => ( <ProfileCard key={item.did} + testID={`searchAutoCompleteResult-${item.handle}`} handle={item.handle} displayName={item.displayName} avatar={item.avatar} diff --git a/src/view/shell/BottomBar.tsx b/src/view/shell/BottomBar.tsx index bfbd7f0a2..e46eeb991 100644 --- a/src/view/shell/BottomBar.tsx +++ b/src/view/shell/BottomBar.tsx @@ -112,6 +112,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { footerMinimalShellTransform, ]}> <Btn + testID="bottomBarHomeBtn" icon={ isAtHome ? ( <HomeIconSolid @@ -130,6 +131,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { onPress={onPressHome} /> <Btn + testID="bottomBarSearchBtn" icon={ isAtSearch ? ( <MagnifyingGlassIcon2Solid @@ -148,6 +150,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { onPress={onPressSearch} /> <Btn + testID="bottomBarNotificationsBtn" icon={ isAtNotifications ? ( <BellIconSolid @@ -167,6 +170,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { notificationCount={store.me.notifications.unreadCount} /> <Btn + testID="bottomBarProfileBtn" icon={ <View style={styles.ctrlIconSizingWrapper}> <UserIcon @@ -183,11 +187,13 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { }) function Btn({ + testID, icon, notificationCount, onPress, onLongPress, }: { + testID?: string icon: JSX.Element notificationCount?: number onPress?: (event: GestureResponderEvent) => void @@ -195,6 +201,7 @@ function Btn({ }) { return ( <TouchableOpacity + testID={testID} style={styles.ctrl} onPress={onLongPress ? onPress : undefined} onPressIn={onLongPress ? undefined : onPress} diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index a33cf8c4e..ccf64c0e6 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -162,7 +162,7 @@ export const DrawerContent = observer(() => { return ( <View - testID="menuView" + testID="drawer" style={[ styles.view, theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode, diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index eec0f8ed4..84242c283 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -7,11 +7,9 @@ import {useNavigationState} from '@react-navigation/native' import {useStores} from 'state/index' import {ModalsContainer} from 'view/com/modals/Modal' import {Lightbox} from 'view/com/lightbox/Lightbox' -import {Text} from 'view/com/util/text/Text' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {DrawerContent} from './Drawer' import {Composer} from './Composer' -import {s} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {RoutesContainer, TabsNavigator} from '../../Navigation' @@ -72,41 +70,6 @@ const ShellInner = observer(() => { export const Shell: React.FC = observer(() => { const theme = useTheme() const pal = usePalette('default') - const store = useStores() - - if (store.hackUpgradeNeeded) { - return ( - <View style={styles.outerContainer}> - <View style={[s.flexCol, s.p20, s.h100pct]}> - <View style={s.flex1} /> - <View> - <Text type="title-2xl" style={s.pb10}> - Update required - </Text> - <Text style={[s.pb20, s.bold]}> - Please update your app to the latest version. If no update is - available yet, please check the App Store in a day or so. - </Text> - <Text type="title" style={s.pb10}> - What's happening? - </Text> - <Text style={s.pb10}> - We're in the final stages of the AT Protocol's v1 development. To - make sure everything works as well as possible, we're making final - breaking changes to the APIs. - </Text> - <Text> - If we didn't botch this process, a new version of the app should - be available now. - </Text> - </View> - <View style={s.flex1} /> - <View style={s.footerSpacer} /> - </View> - </View> - ) - } - return ( <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}> <StatusBar |