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:
Paul Frazee 2023-03-31 13:17:26 -05:00 committed by GitHub
parent 19f3a2fa92
commit a3334a01a2
133 changed files with 3103 additions and 2839 deletions

View file

@ -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)

View file

@ -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} />

View file

@ -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 {

View file

@ -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
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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
// =

View 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
}

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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'},

View file

@ -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
}

View file

@ -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)
}

View file

@ -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',

View file

@ -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})

View file

@ -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() {}

View file

@ -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

View file

@ -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}),
),
)

View file

@ -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

View file

@ -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,
)
}

View file

@ -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)
}
}

View file

@ -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}`

View file

@ -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(() => {

View file

@ -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) {

View file

@ -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) {

View file

@ -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

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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) {

View file

@ -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)

View file

@ -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.'
}

View file

@ -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() {

View file

@ -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)

View file

@ -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

View file

@ -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)
}

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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="&nbsp;(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 ? (

View file

@ -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]}>

View file

@ -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}

View file

@ -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,
})

View file

@ -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: {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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}>

View file

@ -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)) {

View file

@ -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
}
/>

View file

@ -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

View file

@ -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}}

View file

@ -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,

View file

@ -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>

View file

@ -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}}

View file

@ -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}}

View file

@ -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}}

View file

@ -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>}

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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,
},

View file

@ -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]}>
&middot; {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]}>
&middot; {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}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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) {

View file

@ -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} />}
/>
)
},

View file

@ -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}

View file

@ -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}

View file

@ -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]}

View file

@ -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}

View file

@ -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}

View file

@ -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: {

View file

@ -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

View file

@ -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>

View file

@ -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}}

View file

@ -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]}
/>
)
}

View file

@ -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}>

View file

@ -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

View file

@ -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}

View file

@ -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)}>

View file

@ -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 ? (

View file

@ -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}

View file

@ -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>
)
}

View file

@ -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

View file

@ -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: {

View 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',
},
})

View file

@ -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,
},

View file

@ -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)
}
}

View file

@ -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}

View file

@ -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(() => {

View file

@ -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}

View file

@ -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