Lex refactor (#362)
* Remove the hackcheck for upgrades * Rename the PostEmbeds folder to match the codebase style * Updates to latest lex refactor * Update to use new bsky agent * Update to use api package's richtext library * Switch to upsertProfile * Add TextEncoder/TextDecoder polyfill * Add Intl.Segmenter polyfill * Update composer to calculate lengths by grapheme * Fix detox * Fix login in e2e * Create account e2e passing * Implement an e2e mocking framework * Don't use private methods on mobx models as mobx can't track them * Add tooling for e2e-specific builds and add e2e media-picker mock * Add some tests and fix some bugs around profile editing * Add shell tests * Add home screen tests * Add thread screen tests * Add tests for other user profile screens * Add search screen tests * Implement profile imagery change tools and tests * Update to new embed behaviors * Add post tests * Fix to profile-screen test * Fix session resumption * Update web composer to new api * 1.11.0 * Fix pagination cursor parameters * Add quote posts to notifications * Fix embed layouts * Remove youtube inline player and improve tap handling on link cards * Reset minimal shell mode on all screen loads and feed swipes (close #299) * Update podfile.lock * Improve post notfound UI (close #366) * Bump atproto packages
This commit is contained in:
parent
19f3a2fa92
commit
a3334a01a2
133 changed files with 3103 additions and 2839 deletions
|
@ -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)
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
// =
|
||||
|
||||
|
|
116
src/lib/media/picker.e2e.tsx
Normal file
116
src/lib/media/picker.e2e.tsx
Normal file
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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'},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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})
|
||||
|
|
2
src/state/models/cache/image-sizes.ts
vendored
2
src/state/models/cache/image-sizes.ts
vendored
|
@ -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() {}
|
||||
|
||||
|
|
21
src/state/models/cache/my-follows.ts
vendored
21
src/state/models/cache/my-follows.ts
vendored
|
@ -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
|
||||
|
|
|
@ -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}),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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}`
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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 isQuote() {
|
||||
return this.reason === 'quote'
|
||||
}
|
||||
|
||||
get isFollow() {
|
||||
return this.reason === 'follow'
|
||||
}
|
||||
|
||||
get isAssertion() {
|
||||
return this.reason === 'assertion'
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.'
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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]}>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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} />}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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]}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)}>
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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: {
|
55
src/view/com/util/post-embeds/YoutubeEmbed.tsx
Normal file
55
src/view/com/util/post-embeds/YoutubeEmbed.tsx
Normal file
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
})
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue