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
zio/stable
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

@ -3,7 +3,7 @@ module.exports = {
testRunner: {
args: {
$0: 'jest',
config: 'e2e/jest.config.js',
config: '__e2e__/jest.config.js',
},
jest: {
setupTimeout: 120000,
@ -12,15 +12,16 @@ module.exports = {
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/app.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/bluesky.app',
build:
'xcodebuild -workspace ios/app.xcworkspace -scheme app -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
'xcodebuild -workspace ios/bluesky.xcworkspace -scheme bluesky -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
},
'ios.release': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/app.app',
binaryPath:
'ios/build/Build/Products/Release-iphonesimulator/bluesky.app',
build:
'xcodebuild -workspace ios/app.xcworkspace -scheme app -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
'xcodebuild -workspace ios/bluesky.xcworkspace -scheme bluesky -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
},
'android.debug': {
type: 'android.apk',

View File

@ -18,6 +18,12 @@
- iOS: `yarn ios`
- Android: `yarn android`
- Web: `yarn web`
- Run e2e tests
- Start in various console tabs:
- `yarn e2e:server`
- `yarn e2e:metro`
- Run once: `yarn e2e:build`
- Each test run: `yarn e2e:run`
- Tips
- `npx react-native info` Checks what has been installed.
- On M1 macs, [you need to exclude "arm64" from the target architectures](https://stackoverflow.com/a/65399525)

View File

@ -1,7 +1,7 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.test.js'],
testMatch: ['<rootDir>/__e2e__/**/*.test.ts'],
testTimeout: 120000,
maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup',

View File

@ -0,0 +1,75 @@
import {createServer as createHTTPServer} from 'node:http'
import {parse} from 'node:url'
import {createServer, TestPDS} from '../jest/test-pds'
async function main() {
let server: TestPDS
createHTTPServer(async (req, res) => {
const url = parse(req.url || '/', true)
if (req.method !== 'POST') {
return res.writeHead(200).end()
}
try {
console.log('Closing old server')
await server?.close()
console.log('Starting new server')
server = await createServer()
console.log('Listening at', server.pdsUrl)
if (url?.query) {
if ('users' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
}
if ('follows' in url.query) {
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
await server.mocker.follow('bob', 'alice')
await server.mocker.follow('bob', 'carla')
await server.mocker.follow('carla', 'alice')
await server.mocker.follow('carla', 'bob')
}
if ('posts' in url.query) {
console.log('Generating mock posts')
for (let user in server.mocker.users) {
await server.mocker.users[user].agent.post({text: 'Post'})
}
}
if ('thread' in url.query) {
console.log('Generating mock posts')
const res = await server.mocker.users.bob.agent.post({
text: 'Thread root',
})
await server.mocker.users.carla.agent.post({
text: 'Thread reply',
reply: {
parent: {cid: res.cid, uri: res.uri},
root: {cid: res.cid, uri: res.uri},
},
})
}
}
console.log('Ready')
return res.writeHead(200).end(server.pdsUrl)
} catch (e) {
console.error('Error!', e)
return res.writeHead(500).end()
}
}).listen(1986)
console.log('Mock server manager listening on 1986')
}
main()

View File

@ -0,0 +1,108 @@
/* eslint-env detox/detox */
import {openApp, login, createServer, sleep} from '../util'
describe('Composer', () => {
let service: string
beforeAll(async () => {
service = await createServer('?users')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
it('Login', async () => {
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
it('Post text only', async () => {
await element(by.id('composeFAB')).tap()
await device.takeScreenshot('1- opened composer')
await element(by.id('composerTextInput')).typeText('Post text only')
await device.takeScreenshot('2- entered text')
await element(by.id('composerPublishBtn')).tap()
await device.takeScreenshot('3- opened general section')
await expect(element(by.id('composeFAB'))).toBeVisible()
})
it('Post with an image', async () => {
await element(by.id('composeFAB')).tap()
await element(by.id('composerTextInput')).typeText('Post with an image')
await element(by.id('openGalleryBtn')).tap()
await sleep(1e3)
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
it('Post with a link card', async () => {
await element(by.id('composeFAB')).tap()
await element(by.id('composerTextInput')).typeText(
'Post with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
it('Reply text only', async () => {
const post = by.id('feedItem-by-alice.test')
await element(by.id('replyBtn').withAncestor(post)).atIndex(0).tap()
await element(by.id('composerTextInput')).typeText('Reply text only')
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
it('Reply with an image', async () => {
const post = by.id('feedItem-by-alice.test')
await element(by.id('replyBtn').withAncestor(post)).atIndex(0).tap()
await element(by.id('composerTextInput')).typeText('Reply with an image')
await element(by.id('openGalleryBtn')).tap()
await sleep(1e3)
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
it('Reply with a link card', async () => {
const post = by.id('feedItem-by-alice.test')
await element(by.id('replyBtn').withAncestor(post)).atIndex(0).tap()
await element(by.id('composerTextInput')).typeText(
'Reply with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
it('QP text only', async () => {
const post = by.id('feedItem-by-alice.test')
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await element(by.id('quoteBtn').withAncestor(by.id('repostModal'))).tap()
await element(by.id('composerTextInput')).typeText('QP text only')
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
it('QP with an image', async () => {
const post = by.id('feedItem-by-alice.test')
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await element(by.id('quoteBtn').withAncestor(by.id('repostModal'))).tap()
await element(by.id('composerTextInput')).typeText('QP with an image')
await element(by.id('openGalleryBtn')).tap()
await sleep(1e3)
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
it('QP with a link card', async () => {
const post = by.id('feedItem-by-alice.test')
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await element(by.id('quoteBtn').withAncestor(by.id('repostModal'))).tap()
await element(by.id('composerTextInput')).typeText(
'QP with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
})

View File

@ -0,0 +1,31 @@
/* eslint-env detox/detox */
import {openApp, createServer} from '../util'
describe('Create account', () => {
let service: string
beforeAll(async () => {
service = await createServer('mock0')
await openApp({permissions: {notifications: 'YES'}})
})
it('I can create a new account', async () => {
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('otherServerBtn')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customServerInput')).clearText()
await element(by.id('customServerInput')).typeText(service)
await device.takeScreenshot('3- input test server URL')
await element(by.id('nextBtn')).tap()
await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await element(by.id('is13Input')).tap()
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
})
})

View File

@ -0,0 +1,92 @@
/* eslint-env detox/detox */
import {openApp, login, createServer} from '../util'
describe('Home screen', () => {
let service: string
beforeAll(async () => {
service = await createServer('?users&follows&posts')
await openApp({permissions: {notifications: 'YES'}})
})
it('Login', async () => {
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
it('Can like posts', async () => {
const carlaPosts = by.id('feedItem-by-carla.test')
await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0')
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('1')
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0')
})
it('Can repost posts', async () => {
const carlaPosts = by.id('feedItem-by-carla.test')
await expect(
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0')
await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('1')
await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0')
})
it('Can report posts', async () => {
const carlaPosts = by.id('feedItem-by-carla.test')
await element(by.id('postDropdownBtn').withAncestor(carlaPosts))
.atIndex(0)
.tap()
await element(by.id('postDropdownReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(by.id('reportPostRadios-spam')).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
})
it('Can swipe between feeds', async () => {
await element(by.id('homeScreen')).swipe('left', 'fast', 0.75)
await expect(element(by.id('whatshotFeedPage'))).toBeVisible()
await element(by.id('homeScreen')).swipe('right', 'fast', 0.75)
await expect(element(by.id('followingFeedPage'))).toBeVisible()
})
it('Can tap between feeds', async () => {
await element(by.id("homeScreenFeedTabs-What's hot")).tap()
await expect(element(by.id('whatshotFeedPage'))).toBeVisible()
await element(by.id('homeScreenFeedTabs-Following')).tap()
await expect(element(by.id('followingFeedPage'))).toBeVisible()
})
it('Can delete posts', async () => {
const alicePosts = by.id('feedItem-by-alice.test')
await expect(element(alicePosts.withDescendant(by.text('Post')))).toExist()
await element(by.id('postDropdownBtn').withAncestor(alicePosts))
.atIndex(0)
.tap()
await element(by.id('postDropdownDeleteBtn')).tap()
await expect(element(by.id('confirmModal'))).toBeVisible()
await element(by.id('confirmBtn')).tap()
await expect(
element(alicePosts.withDescendant(by.text('Post'))),
).not.toExist()
})
})

View File

@ -0,0 +1,19 @@
/* eslint-env detox/detox */
import {openApp, login, createServer} from '../util'
describe('Login', () => {
let service: string
beforeAll(async () => {
service = await createServer('?users')
await openApp({permissions: {notifications: 'YES'}})
})
it('As Alice, I can login', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await login(service, 'alice', 'hunter2', {
takeScreenshots: true,
})
await device.takeScreenshot('5- opened home screen')
})
})

View File

@ -0,0 +1,173 @@
/* eslint-env detox/detox */
import {openApp, login, createServer, sleep} from '../util'
describe('Profile screen', () => {
let service: string
beforeAll(async () => {
service = await createServer('?users&posts')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
it('Login and navigate to my profile', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await login(service, 'alice', 'hunter2')
await element(by.id('bottomBarProfileBtn')).tap()
})
it('Open and close edit profile modal', async () => {
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('editProfileCancelBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
})
it('Edit display name and description via the edit profile modal', async () => {
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('editProfileDisplayNameInput')).clearText()
await element(by.id('editProfileDisplayNameInput')).typeText('Alicia')
await element(by.id('editProfileDescriptionInput')).clearText()
await element(by.id('editProfileDescriptionInput')).typeText(
'One cool hacker',
)
await element(by.id('editProfileSaveBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
await expect(element(by.id('profileHeaderDisplayName'))).toHaveText(
'Alicia',
)
await expect(element(by.id('profileHeaderDescription'))).toHaveText(
'One cool hacker',
)
})
it('Remove display name and description via the edit profile modal', async () => {
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('editProfileDisplayNameInput')).clearText()
await element(by.id('editProfileDescriptionInput')).clearText()
await element(by.id('editProfileSaveBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
await expect(element(by.id('profileHeaderDisplayName'))).toHaveText(
'alice.test',
)
await expect(element(by.id('profileHeaderDescription'))).toHaveText('')
})
it('Set avi and banner via the edit profile modal', async () => {
await expect(element(by.id('userBannerFallback'))).toExist()
await expect(element(by.id('userAvatarFallback'))).toExist()
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('changeBannerBtn')).tap()
await element(by.id('changeBannerLibraryBtn')).tap()
await sleep(3e3)
await element(by.id('changeAvatarBtn')).tap()
await element(by.id('changeAvatarLibraryBtn')).tap()
await sleep(3e3)
await element(by.id('editProfileSaveBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
await expect(element(by.id('userBannerImage'))).toExist()
await expect(element(by.id('userAvatarImage'))).toExist()
})
it('Remove avi and banner via the edit profile modal', async () => {
await expect(element(by.id('userBannerImage'))).toExist()
await expect(element(by.id('userAvatarImage'))).toExist()
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('changeBannerBtn')).tap()
await element(by.id('changeBannerRemoveBtn')).tap()
await element(by.id('changeAvatarBtn')).tap()
await element(by.id('changeAvatarRemoveBtn')).tap()
await element(by.id('editProfileSaveBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
await expect(element(by.id('userBannerFallback'))).toExist()
await expect(element(by.id('userAvatarFallback'))).toExist()
})
it('Navigate to another user profile', async () => {
await element(by.id('bottomBarSearchBtn')).tap()
// have to wait for the toast to clear
await waitFor(element(by.id('searchTextInput')))
.toBeVisible()
.withTimeout(2000)
await element(by.id('searchTextInput')).typeText('bob')
await element(by.id('searchAutoCompleteResult-bob.test')).tap()
await expect(element(by.id('profileView'))).toBeVisible()
})
it('Can follow/unfollow another user', async () => {
await element(by.id('followBtn')).tap()
await expect(element(by.id('unfollowBtn'))).toBeVisible()
await element(by.id('unfollowBtn')).tap()
await expect(element(by.id('followBtn'))).toBeVisible()
})
it('Can mute/unmute another user', async () => {
await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist()
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.id('profileHeaderDropdownMuteBtn')).tap()
await expect(element(by.id('profileHeaderMutedNotice'))).toBeVisible()
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.id('profileHeaderDropdownMuteBtn')).tap()
await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist()
})
it('Can report another user', async () => {
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.id('profileHeaderDropdownReportBtn')).tap()
await expect(element(by.id('reportAccountModal'))).toBeVisible()
await element(by.id('reportAccountRadios-spam')).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportAccountModal'))).not.toBeVisible()
})
it('Can like posts', async () => {
const posts = by.id('feedItem-by-bob.test')
await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
).toHaveText('0')
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
).toHaveText('1')
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
).toHaveText('0')
})
it('Can repost posts', async () => {
const posts = by.id('feedItem-by-bob.test')
await expect(
element(by.id('repostCount').withAncestor(posts)).atIndex(0),
).toHaveText('0')
await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(posts)).atIndex(0),
).toHaveText('1')
await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(posts)).atIndex(0),
).toHaveText('0')
})
it('Can report posts', async () => {
const posts = by.id('feedItem-by-bob.test')
await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap()
await element(by.id('postDropdownReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(by.id('reportPostRadios-spam')).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
})
})

View File

@ -0,0 +1,24 @@
/* eslint-env detox/detox */
import {openApp, login, createServer} from '../util'
describe('Search screen', () => {
let service: string
beforeAll(async () => {
service = await createServer('?users')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
it('Login', async () => {
await login(service, 'alice', 'hunter2')
})
it('Navigate to another user profile via autocomplete', async () => {
await element(by.id('bottomBarSearchBtn')).tap()
await element(by.id('searchTextInput')).typeText('bob')
await element(by.id('searchAutoCompleteResult-bob.test')).tap()
await expect(element(by.id('profileView'))).toBeVisible()
})
})

View File

@ -0,0 +1,34 @@
/* eslint-env detox/detox */
import {openApp, login, createServer} from '../util'
describe('Shell', () => {
let service: string
beforeAll(async () => {
service = await createServer('?users')
await openApp({permissions: {notifications: 'YES'}})
})
it('Login', async () => {
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
it('Can swipe the shelf open', async () => {
await element(by.id('homeScreen')).swipe('right', 'fast', 0.75)
await expect(element(by.id('drawer'))).toBeVisible()
await element(by.id('drawer')).swipe('left', 'fast', 0.75)
await expect(element(by.id('drawer'))).not.toBeVisible()
})
it('Can open the shelf by pressing the header avi', async () => {
await element(by.id('viewHeaderDrawerBtn')).tap()
await expect(element(by.id('drawer'))).toBeVisible()
})
it('Can navigate using the shelf', async () => {
await element(by.id('menuItemButton-Notifications')).tap()
await expect(element(by.id('drawer'))).not.toBeVisible()
await expect(element(by.id('notificationsScreen'))).toBeVisible()
})
})

View File

@ -0,0 +1,123 @@
/* eslint-env detox/detox */
import {openApp, login, createServer} from '../util'
describe('Thread screen', () => {
let service: string
beforeAll(async () => {
service = await createServer('?users&follows&thread')
await openApp({permissions: {notifications: 'YES'}})
})
it('Login & navigate to thread', async () => {
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
await element(by.id('feedItem-by-bob.test')).atIndex(0).tap()
await expect(
element(
by
.id('postThreadItem-by-bob.test')
.withDescendant(by.text('Thread root')),
),
).toBeVisible()
await expect(
element(
by
.id('postThreadItem-by-carla.test')
.withDescendant(by.text('Thread reply')),
),
).toBeVisible()
})
it('Can like the root post', async () => {
const post = by.id('postThreadItem-by-bob.test')
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).not.toExist()
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).toHaveText('1 like')
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).not.toExist()
})
it('Can like a reply post', async () => {
const post = by.id('postThreadItem-by-carla.test')
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).toHaveText('0')
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).toHaveText('1')
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).toHaveText('0')
})
it('Can repost the root post', async () => {
const post = by.id('postThreadItem-by-bob.test')
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).not.toExist()
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).toHaveText('1 repost')
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).not.toExist()
})
it('Can repost a reply post', async () => {
const post = by.id('postThreadItem-by-carla.test')
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).toHaveText('0')
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).toHaveText('1')
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).toHaveText('0')
})
it('Can report the root post', async () => {
const post = by.id('postThreadItem-by-bob.test')
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
await element(by.id('postDropdownReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(by.id('reportPostRadios-spam')).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
})
it('Can report a reply post', async () => {
const post = by.id('postThreadItem-by-carla.test')
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
await element(by.id('postDropdownReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(by.id('reportPostRadios-spam')).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
})
})

96
__e2e__/util.ts 100644
View File

@ -0,0 +1,96 @@
import {resolveConfig} from 'detox/internals'
const platform = device.getPlatform()
export async function openApp(opts: any) {
opts = opts || {}
const config = await resolveConfig()
if (config.configurationName.split('.').includes('debug')) {
return await openAppForDebugBuild(platform, opts)
} else {
return await device.launchApp({
...opts,
newInstance: true,
})
}
}
export async function isVisible(id: string) {
try {
await expect(element(by.id(id))).toBeVisible()
return true
} catch (e) {
return false
}
}
export async function login(
service: string,
username: string,
password: string,
{takeScreenshots} = {takeScreenshots: false},
) {
await element(by.id('signInButton')).tap()
if (takeScreenshots) {
await device.takeScreenshot('1- opened sign-in screen')
}
if (await isVisible('chooseAccountForm')) {
await element(by.id('chooseNewAccountBtn')).tap()
}
await element(by.id('loginSelectServiceButton')).tap()
if (takeScreenshots) {
await device.takeScreenshot('2- opened service selector')
}
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerSelectBtn')).tap()
if (takeScreenshots) {
await device.takeScreenshot('3- input custom service')
}
await element(by.id('loginUsernameInput')).typeText(username)
await element(by.id('loginPasswordInput')).typeText(password)
if (takeScreenshots) {
await device.takeScreenshot('4- entered username and password')
}
await element(by.id('loginNextButton')).tap()
}
async function openAppForDebugBuild(platform: string, opts: any) {
const deepLinkUrl = // Local testing with packager
/*process.env.EXPO_USE_UPDATES
? // Testing latest published EAS update for the test_debug channel
getDeepLinkUrl(getLatestUpdateUrl())
: */ getDeepLinkUrl(getDevLauncherPackagerUrl(platform))
if (platform === 'ios') {
await device.launchApp({
...opts,
newInstance: true,
})
sleep(3000)
await device.openURL({
url: deepLinkUrl,
})
} else {
await device.launchApp({
...opts,
newInstance: true,
url: deepLinkUrl,
})
}
await sleep(3000)
}
export async function createServer(path = '') {
const res = await fetch(`http://localhost:1986/${path}`, {method: 'POST'})
const resBody = await res.text()
return resBody
}
const getDeepLinkUrl = (url: string) =>
`expo+bluesky://expo-development-client/?url=${encodeURIComponent(url)}`
const getDevLauncherPackagerUrl = (platform: string) =>
`http://localhost:8081/index.bundle?platform=${platform}&dev=true&minify=false&disableOnboarding=1`
export const sleep = (t: number) => new Promise(res => setTimeout(res, t))

View File

@ -4,14 +4,14 @@ import {
getLikelyType,
} from '../../src/lib/link-meta/link-meta'
import {exampleComHtml} from './__mocks__/exampleComHtml'
import AtpAgent from '@atproto/api'
import {BskyAgent} from '@atproto/api'
import {DEFAULT_SERVICE, RootStoreModel} from '../../src/state'
describe('getLinkMeta', () => {
let rootStore: RootStoreModel
beforeEach(() => {
rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE}))
rootStore = new RootStoreModel(new BskyAgent({service: DEFAULT_SERVICE}))
})
const inputs = [

View File

@ -7,172 +7,10 @@ import {
} from '../../src/lib/strings/url-helpers'
import {pluralize, enforceLen} from '../../src/lib/strings/helpers'
import {ago} from '../../src/lib/strings/time'
import {
extractEntities,
detectLinkables,
} from '../../src/lib/strings/rich-text-detection'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles'
import {cleanError} from '../../src/lib/strings/errors'
describe('extractEntities', () => {
const knownHandles = new Set(['handle.com', 'full123.test-of-chars'])
const inputs = [
'no mention',
'@handle.com middle end',
'start @handle.com end',
'start middle @handle.com',
'@handle.com @handle.com @handle.com',
'@full123.test-of-chars',
'not@right',
'@handle.com!@#$chars',
'@handle.com\n@handle.com',
'parenthetical (@handle.com)',
'start https://middle.com end',
'start https://middle.com/foo/bar end',
'start https://middle.com/foo/bar?baz=bux end',
'start https://middle.com/foo/bar?baz=bux#hash end',
'https://start.com/foo/bar?baz=bux#hash middle end',
'start middle https://end.com/foo/bar?baz=bux#hash',
'https://newline1.com\nhttps://newline2.com',
'start middle.com end',
'start middle.com/foo/bar end',
'start middle.com/foo/bar?baz=bux end',
'start middle.com/foo/bar?baz=bux#hash end',
'start.com/foo/bar?baz=bux#hash middle end',
'start middle end.com/foo/bar?baz=bux#hash',
'newline1.com\nnewline2.com',
'not.. a..url ..here',
'e.g.',
'something-cool.jpg',
'website.com.jpg',
'e.g./foo',
'website.com.jpg/foo',
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/ ',
'https://foo.com https://bar.com/whatever https://baz.com',
'punctuation https://foo.com, https://bar.com/whatever; https://baz.com.',
'parenthentical (https://foo.com)',
'except for https://foo.com/thing_(cool)',
]
interface Output {
type: string
value: string
noScheme?: boolean
}
const outputs: Output[][] = [
[],
[{type: 'mention', value: 'handle.com'}],
[{type: 'mention', value: 'handle.com'}],
[{type: 'mention', value: 'handle.com'}],
[
{type: 'mention', value: 'handle.com'},
{type: 'mention', value: 'handle.com'},
{type: 'mention', value: 'handle.com'},
],
[
{
type: 'mention',
value: 'full123.test-of-chars',
},
],
[],
[{type: 'mention', value: 'handle.com'}],
[
{type: 'mention', value: 'handle.com'},
{type: 'mention', value: 'handle.com'},
],
[{type: 'mention', value: 'handle.com'}],
[{type: 'link', value: 'https://middle.com'}],
[{type: 'link', value: 'https://middle.com/foo/bar'}],
[{type: 'link', value: 'https://middle.com/foo/bar?baz=bux'}],
[{type: 'link', value: 'https://middle.com/foo/bar?baz=bux#hash'}],
[{type: 'link', value: 'https://start.com/foo/bar?baz=bux#hash'}],
[{type: 'link', value: 'https://end.com/foo/bar?baz=bux#hash'}],
[
{type: 'link', value: 'https://newline1.com'},
{type: 'link', value: 'https://newline2.com'},
],
[{type: 'link', value: 'middle.com', noScheme: true}],
[{type: 'link', value: 'middle.com/foo/bar', noScheme: true}],
[{type: 'link', value: 'middle.com/foo/bar?baz=bux', noScheme: true}],
[{type: 'link', value: 'middle.com/foo/bar?baz=bux#hash', noScheme: true}],
[{type: 'link', value: 'start.com/foo/bar?baz=bux#hash', noScheme: true}],
[{type: 'link', value: 'end.com/foo/bar?baz=bux#hash', noScheme: true}],
[
{type: 'link', value: 'newline1.com', noScheme: true},
{type: 'link', value: 'newline2.com', noScheme: true},
],
[],
[],
[],
[],
[],
[],
[
{
type: 'link',
value:
'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
},
],
[
{
type: 'link',
value:
'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
},
],
[
{type: 'link', value: 'https://foo.com'},
{type: 'link', value: 'https://bar.com/whatever'},
{type: 'link', value: 'https://baz.com'},
],
[
{type: 'link', value: 'https://foo.com'},
{type: 'link', value: 'https://bar.com/whatever'},
{type: 'link', value: 'https://baz.com'},
],
[{type: 'link', value: 'https://foo.com'}],
[{type: 'link', value: 'https://foo.com/thing_(cool)'}],
]
it('correctly handles a set of text inputs', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const result = extractEntities(input, knownHandles)
if (!outputs[i].length) {
expect(result).toBeFalsy()
} else if (outputs[i].length && !result) {
expect(result).toBeTruthy()
} else if (result) {
expect(result.length).toBe(outputs[i].length)
for (let j = 0; j < outputs[i].length; j++) {
expect(result[j].type).toEqual(outputs[i][j].type)
if (outputs[i][j].noScheme) {
expect(result[j].value).toEqual(`https://${outputs[i][j].value}`)
} else {
expect(result[j].value).toEqual(outputs[i][j].value)
}
if (outputs[i]?.[j].type === 'mention') {
expect(
input.slice(result[j].index.start, result[j].index.end),
).toBe(`@${result[j].value}`)
} else {
if (!outputs[i]?.[j].noScheme) {
expect(
input.slice(result[j].index.start, result[j].index.end),
).toBe(result[j].value)
} else {
expect(
input.slice(result[j].index.start, result[j].index.end),
).toBe(result[j].value.slice('https://'.length))
}
}
}
}
}
})
})
describe('detectLinkables', () => {
const inputs = [
'no linkable',

View File

@ -1,123 +0,0 @@
import {AppBskyFeedPost} from '@atproto/api'
type Entity = AppBskyFeedPost.Entity
import {RichText} from '../../../src/lib/strings/rich-text'
import {removeExcessNewlines} from '../../../src/lib/strings/rich-text-sanitize'
describe('removeExcessNewlines', () => {
it('removes more than two consecutive new lines', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with spaces', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n \n \n \n \n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n \n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('returns original string if there are no consecutive new lines', () => {
const input = new RichText('test\n\ntest\n\ntest\n\ntest\n\ntest')
const output = removeExcessNewlines(input)
expect(output.text).toEqual(input.text)
})
it('returns original string if there are no new lines', () => {
const input = new RichText('test test test test test')
const output = removeExcessNewlines(input)
expect(output.text).toEqual(input.text)
})
it('returns empty string if input is empty', () => {
const input = new RichText('')
const output = removeExcessNewlines(input)
expect(output.text).toEqual('')
})
it('works with different types of new line characters', () => {
const input = new RichText(
'test\r\ntest\n\rtest\rtest\n\n\n\ntest\n\r \n \n \n \n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\r\ntest\n\rtest\rtest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with zero width space', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\u200B\u200B\n\n\n\ntest\n \u200B\u200B \n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with zero width non-joiner', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\u200C\u200C\n\n\n\ntest\n \u200C\u200C \n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with zero width joiner', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\u200D\u200D\n\n\n\ntest\n \u200D\u200D \n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with soft hyphen', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\u00AD\u00AD\n\n\n\ntest\n \u00AD\u00AD \n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
it('removes more than two consecutive new lines with word joiner', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\u2060\u2060\n\n\n\ntest\n \u2060\u2060 \n\n\n\ntest\n\n\n\n\n\n\ntest',
)
const output = removeExcessNewlines(input)
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
})
})
describe('removeExcessNewlines w/entities', () => {
it('preserves entities as expected', () => {
const input = new RichText(
'test\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest',
[
{index: {start: 0, end: 13}, type: '', value: ''},
{index: {start: 13, end: 24}, type: '', value: ''},
{index: {start: 9, end: 15}, type: '', value: ''},
{index: {start: 4, end: 9}, type: '', value: ''},
],
)
const output = removeExcessNewlines(input)
expect(entToStr(input.text, input.entities?.[0])).toEqual(
'test\n\n\n\n\ntest',
)
expect(entToStr(input.text, input.entities?.[1])).toEqual(
'\n\n\n\n\n\n\ntest',
)
expect(entToStr(input.text, input.entities?.[2])).toEqual('test\n\n')
expect(entToStr(input.text, input.entities?.[3])).toEqual('\n\n\n\n\n')
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
expect(entToStr(output.text, output.entities?.[0])).toEqual('test\n\ntest')
expect(entToStr(output.text, output.entities?.[1])).toEqual('test')
expect(entToStr(output.text, output.entities?.[2])).toEqual('test')
expect(output.entities?.[3]).toEqual(undefined)
})
})
function entToStr(str: string, ent?: Entity) {
if (!ent) {
return ''
}
return str.slice(ent.index.start, ent.index.end)
}

View File

@ -1,123 +0,0 @@
import {RichText} from '../../../src/lib/strings/rich-text'
describe('richText.insert', () => {
const input = new RichText('hello world', [
{index: {start: 2, end: 7}, type: '', value: ''},
])
it('correctly adjusts entities (scenario A - before)', () => {
const output = input.clone().insert(0, 'test')
expect(output.text).toEqual('testhello world')
expect(output.entities?.[0].index.start).toEqual(6)
expect(output.entities?.[0].index.end).toEqual(11)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('llo w')
})
it('correctly adjusts entities (scenario B - inner)', () => {
const output = input.clone().insert(4, 'test')
expect(output.text).toEqual('helltesto world')
expect(output.entities?.[0].index.start).toEqual(2)
expect(output.entities?.[0].index.end).toEqual(11)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('lltesto w')
})
it('correctly adjusts entities (scenario C - after)', () => {
const output = input.clone().insert(8, 'test')
expect(output.text).toEqual('hello wotestrld')
expect(output.entities?.[0].index.start).toEqual(2)
expect(output.entities?.[0].index.end).toEqual(7)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('llo w')
})
})
describe('richText.delete', () => {
const input = new RichText('hello world', [
{index: {start: 2, end: 7}, type: '', value: ''},
])
it('correctly adjusts entities (scenario A - entirely outer)', () => {
const output = input.clone().delete(0, 9)
expect(output.text).toEqual('ld')
expect(output.entities?.length).toEqual(0)
})
it('correctly adjusts entities (scenario B - entirely after)', () => {
const output = input.clone().delete(7, 11)
expect(output.text).toEqual('hello w')
expect(output.entities?.[0].index.start).toEqual(2)
expect(output.entities?.[0].index.end).toEqual(7)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('llo w')
})
it('correctly adjusts entities (scenario C - partially after)', () => {
const output = input.clone().delete(4, 11)
expect(output.text).toEqual('hell')
expect(output.entities?.[0].index.start).toEqual(2)
expect(output.entities?.[0].index.end).toEqual(4)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('ll')
})
it('correctly adjusts entities (scenario D - entirely inner)', () => {
const output = input.clone().delete(3, 5)
expect(output.text).toEqual('hel world')
expect(output.entities?.[0].index.start).toEqual(2)
expect(output.entities?.[0].index.end).toEqual(5)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('l w')
})
it('correctly adjusts entities (scenario E - partially before)', () => {
const output = input.clone().delete(1, 5)
expect(output.text).toEqual('h world')
expect(output.entities?.[0].index.start).toEqual(1)
expect(output.entities?.[0].index.end).toEqual(3)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual(' w')
})
it('correctly adjusts entities (scenario F - entirely before)', () => {
const output = input.clone().delete(0, 2)
expect(output.text).toEqual('llo world')
expect(output.entities?.[0].index.start).toEqual(0)
expect(output.entities?.[0].index.end).toEqual(5)
expect(
output.text.slice(
output.entities?.[0].index.start,
output.entities?.[0].index.end,
),
).toEqual('llo w')
})
})

View File

@ -2,7 +2,7 @@
"expo": {
"name": "bluesky",
"slug": "bluesky",
"version": "1.10.0",
"version": "1.11.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",

View File

@ -1,70 +0,0 @@
/* eslint-env detox/detox */
describe('Happy paths', () => {
async function grantAccessToUserWithValidCredentials(
username,
{takeScreenshots} = {takeScreenshots: false},
) {
await element(by.id('signInButton')).tap()
if (takeScreenshots) {
await device.takeScreenshot('1- opened sign-in screen')
}
await element(by.id('loginSelectServiceButton')).tap()
if (takeScreenshots) {
await device.takeScreenshot('2- opened service selector')
}
await element(by.id('localDevServerButton')).tap()
if (takeScreenshots) {
await device.takeScreenshot('3- selected local dev server')
}
await element(by.id('loginUsernameInput')).typeText(username)
await element(by.id('loginPasswordInput')).typeText('hunter2')
if (takeScreenshots) {
await device.takeScreenshot('4- entered username and password')
}
await element(by.id('loginNextButton')).tap()
}
beforeEach(async () => {
await device.uninstallApp()
await device.installApp()
await device.launchApp({permissions: {notifications: 'YES'}})
})
it('As Alice, I can login', async () => {
await expect(element(by.id('signInButton'))).toBeVisible()
await grantAccessToUserWithValidCredentials('alice', {
takeScreenshots: true,
})
await device.takeScreenshot('5- opened home screen')
})
it('As Alice, I can login, and post a text', async () => {
await grantAccessToUserWithValidCredentials('alice')
await element(by.id('composeFAB')).tap()
await device.takeScreenshot('1- opened composer')
await element(by.id('composerTextInput')).typeText(
'Greetings earthlings, I come in peace... and to run some tests.',
)
await device.takeScreenshot('2- entered text')
await element(by.id('composerPublishButton')).tap()
await device.takeScreenshot('3- opened general section')
await expect(element(by.id('composeFAB'))).toBeVisible()
})
it('I can create a new account', async () => {
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('registerSelectServiceButton')).tap()
await device.takeScreenshot('2- opened service selector')
await element(by.id('localDevServerButton')).tap()
await device.takeScreenshot('3- selected local dev server')
await element(by.id('registerEmailInput')).typeText('example@test.com')
await element(by.id('registerPasswordInput')).typeText('hunter2')
await element(by.id('registerHandleInput')).typeText('e2e-test')
await element(by.id('registerIs13Input')).tap()
await device.takeScreenshot('4- entered account details')
await element(by.id('createAccountButton')).tap()
await expect(element(by.id('welcomeBanner'))).toBeVisible()
})
})

View File

@ -22,7 +22,7 @@ PODS:
- EXMediaLibrary (15.2.3):
- ExpoModulesCore
- React-Core
- Expo (48.0.7):
- Expo (48.0.9):
- ExpoModulesCore
- expo-dev-client (2.1.5):
- EXManifests
@ -102,7 +102,7 @@ PODS:
- ExpoModulesCore
- ExpoLocalization (14.1.1):
- ExpoModulesCore
- ExpoModulesCore (1.2.5):
- ExpoModulesCore (1.2.6):
- React-Core
- React-RCTAppDelegate
- ReactCommon/turbomodule/core
@ -110,19 +110,19 @@ PODS:
- ExpoModulesCore
- React-Core
- EXUpdatesInterface (0.9.1)
- FBLazyVector (0.71.3)
- FBReactNativeSpec (0.71.3):
- FBLazyVector (0.71.4)
- FBReactNativeSpec (0.71.4):
- RCT-Folly (= 2021.07.22.00)
- RCTRequired (= 0.71.3)
- RCTTypeSafety (= 0.71.3)
- React-Core (= 0.71.3)
- React-jsi (= 0.71.3)
- ReactCommon/turbomodule/core (= 0.71.3)
- RCTRequired (= 0.71.4)
- RCTTypeSafety (= 0.71.4)
- React-Core (= 0.71.4)
- React-jsi (= 0.71.4)
- ReactCommon/turbomodule/core (= 0.71.4)
- fmt (6.2.1)
- glog (0.3.5)
- hermes-engine (0.71.3):
- hermes-engine/Pre-built (= 0.71.3)
- hermes-engine/Pre-built (0.71.3)
- hermes-engine (0.71.4):
- hermes-engine/Pre-built (= 0.71.4)
- hermes-engine/Pre-built (0.71.4)
- libevent (2.1.12)
- libwebp (1.2.4):
- libwebp/demux (= 1.2.4)
@ -150,26 +150,26 @@ PODS:
- fmt (~> 6.2.1)
- glog
- libevent
- RCTRequired (0.71.3)
- RCTTypeSafety (0.71.3):
- FBLazyVector (= 0.71.3)
- RCTRequired (= 0.71.3)
- React-Core (= 0.71.3)
- React (0.71.3):
- React-Core (= 0.71.3)
- React-Core/DevSupport (= 0.71.3)
- React-Core/RCTWebSocket (= 0.71.3)
- React-RCTActionSheet (= 0.71.3)
- React-RCTAnimation (= 0.71.3)
- React-RCTBlob (= 0.71.3)
- React-RCTImage (= 0.71.3)
- React-RCTLinking (= 0.71.3)
- React-RCTNetwork (= 0.71.3)
- React-RCTSettings (= 0.71.3)
- React-RCTText (= 0.71.3)
- React-RCTVibration (= 0.71.3)
- React-callinvoker (0.71.3)
- React-Codegen (0.71.3):
- RCTRequired (0.71.4)
- RCTTypeSafety (0.71.4):
- FBLazyVector (= 0.71.4)
- RCTRequired (= 0.71.4)
- React-Core (= 0.71.4)
- React (0.71.4):
- React-Core (= 0.71.4)
- React-Core/DevSupport (= 0.71.4)
- React-Core/RCTWebSocket (= 0.71.4)
- React-RCTActionSheet (= 0.71.4)
- React-RCTAnimation (= 0.71.4)
- React-RCTBlob (= 0.71.4)
- React-RCTImage (= 0.71.4)
- React-RCTLinking (= 0.71.4)
- React-RCTNetwork (= 0.71.4)
- React-RCTSettings (= 0.71.4)
- React-RCTText (= 0.71.4)
- React-RCTVibration (= 0.71.4)
- React-callinvoker (0.71.4)
- React-Codegen (0.71.4):
- FBReactNativeSpec
- hermes-engine
- RCT-Folly
@ -180,209 +180,209 @@ PODS:
- React-jsiexecutor
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- React-Core (0.71.3):
- React-Core (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default (= 0.71.3)
- React-cxxreact (= 0.71.3)
- React-Core/Default (= 0.71.4)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/CoreModulesHeaders (0.71.3):
- React-Core/CoreModulesHeaders (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/Default (0.71.3):
- React-Core/Default (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/DevSupport (0.71.3):
- React-Core/DevSupport (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default (= 0.71.3)
- React-Core/RCTWebSocket (= 0.71.3)
- React-cxxreact (= 0.71.3)
- React-Core/Default (= 0.71.4)
- React-Core/RCTWebSocket (= 0.71.4)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-jsinspector (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-jsinspector (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/RCTActionSheetHeaders (0.71.3):
- React-Core/RCTActionSheetHeaders (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/RCTAnimationHeaders (0.71.3):
- React-Core/RCTAnimationHeaders (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/RCTBlobHeaders (0.71.3):
- React-Core/RCTBlobHeaders (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/RCTImageHeaders (0.71.3):
- React-Core/RCTImageHeaders (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/RCTLinkingHeaders (0.71.3):
- React-Core/RCTLinkingHeaders (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/RCTNetworkHeaders (0.71.3):
- React-Core/RCTNetworkHeaders (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/RCTSettingsHeaders (0.71.3):
- React-Core/RCTSettingsHeaders (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/RCTTextHeaders (0.71.3):
- React-Core/RCTTextHeaders (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/RCTVibrationHeaders (0.71.3):
- React-Core/RCTVibrationHeaders (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-Core/RCTWebSocket (0.71.3):
- React-Core/RCTWebSocket (0.71.4):
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default (= 0.71.3)
- React-cxxreact (= 0.71.3)
- React-Core/Default (= 0.71.4)
- React-cxxreact (= 0.71.4)
- React-hermes
- React-jsi (= 0.71.3)
- React-jsiexecutor (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (= 0.71.4)
- React-jsiexecutor (= 0.71.4)
- React-perflogger (= 0.71.4)
- Yoga
- React-CoreModules (0.71.3):
- React-CoreModules (0.71.4):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.71.3)
- React-Codegen (= 0.71.3)
- React-Core/CoreModulesHeaders (= 0.71.3)
- React-jsi (= 0.71.3)
- RCTTypeSafety (= 0.71.4)
- React-Codegen (= 0.71.4)
- React-Core/CoreModulesHeaders (= 0.71.4)
- React-jsi (= 0.71.4)
- React-RCTBlob
- React-RCTImage (= 0.71.3)
- ReactCommon/turbomodule/core (= 0.71.3)
- React-cxxreact (0.71.3):
- React-RCTImage (= 0.71.4)
- ReactCommon/turbomodule/core (= 0.71.4)
- React-cxxreact (0.71.4):
- boost (= 1.76.0)
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-callinvoker (= 0.71.3)
- React-jsi (= 0.71.3)
- React-jsinspector (= 0.71.3)
- React-logger (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-runtimeexecutor (= 0.71.3)
- React-hermes (0.71.3):
- React-callinvoker (= 0.71.4)
- React-jsi (= 0.71.4)
- React-jsinspector (= 0.71.4)
- React-logger (= 0.71.4)
- React-perflogger (= 0.71.4)
- React-runtimeexecutor (= 0.71.4)
- React-hermes (0.71.4):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- RCT-Folly/Futures (= 2021.07.22.00)
- React-cxxreact (= 0.71.3)
- React-cxxreact (= 0.71.4)
- React-jsi
- React-jsiexecutor (= 0.71.3)
- React-jsinspector (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsi (0.71.3):
- React-jsiexecutor (= 0.71.4)
- React-jsinspector (= 0.71.4)
- React-perflogger (= 0.71.4)
- React-jsi (0.71.4):
- boost (= 1.76.0)
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-jsiexecutor (0.71.3):
- React-jsiexecutor (0.71.4):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-cxxreact (= 0.71.3)
- React-jsi (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-jsinspector (0.71.3)
- React-logger (0.71.3):
- React-cxxreact (= 0.71.4)
- React-jsi (= 0.71.4)
- React-perflogger (= 0.71.4)
- React-jsinspector (0.71.4)
- React-logger (0.71.4):
- glog
- react-native-blur (4.3.0):
- React-Core
@ -407,92 +407,90 @@ PODS:
- React-Core
- react-native-version-number (0.3.6):
- React
- react-native-webview (11.26.0):
- React-Core
- React-perflogger (0.71.3)
- React-RCTActionSheet (0.71.3):
- React-Core/RCTActionSheetHeaders (= 0.71.3)
- React-RCTAnimation (0.71.3):
- React-perflogger (0.71.4)
- React-RCTActionSheet (0.71.4):
- React-Core/RCTActionSheetHeaders (= 0.71.4)
- React-RCTAnimation (0.71.4):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.71.3)
- React-Codegen (= 0.71.3)
- React-Core/RCTAnimationHeaders (= 0.71.3)
- React-jsi (= 0.71.3)
- ReactCommon/turbomodule/core (= 0.71.3)
- React-RCTAppDelegate (0.71.3):
- RCTTypeSafety (= 0.71.4)
- React-Codegen (= 0.71.4)
- React-Core/RCTAnimationHeaders (= 0.71.4)
- React-jsi (= 0.71.4)
- ReactCommon/turbomodule/core (= 0.71.4)
- React-RCTAppDelegate (0.71.4):
- RCT-Folly
- RCTRequired
- RCTTypeSafety
- React-Core
- ReactCommon/turbomodule/core
- React-RCTBlob (0.71.3):
- React-RCTBlob (0.71.4):
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-Codegen (= 0.71.3)
- React-Core/RCTBlobHeaders (= 0.71.3)
- React-Core/RCTWebSocket (= 0.71.3)
- React-jsi (= 0.71.3)
- React-RCTNetwork (= 0.71.3)
- ReactCommon/turbomodule/core (= 0.71.3)
- React-RCTImage (0.71.3):
- React-Codegen (= 0.71.4)
- React-Core/RCTBlobHeaders (= 0.71.4)
- React-Core/RCTWebSocket (= 0.71.4)
- React-jsi (= 0.71.4)
- React-RCTNetwork (= 0.71.4)
- ReactCommon/turbomodule/core (= 0.71.4)
- React-RCTImage (0.71.4):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.71.3)
- React-Codegen (= 0.71.3)
- React-Core/RCTImageHeaders (= 0.71.3)
- React-jsi (= 0.71.3)
- React-RCTNetwork (= 0.71.3)
- ReactCommon/turbomodule/core (= 0.71.3)
- React-RCTLinking (0.71.3):
- React-Codegen (= 0.71.3)
- React-Core/RCTLinkingHeaders (= 0.71.3)
- React-jsi (= 0.71.3)
- ReactCommon/turbomodule/core (= 0.71.3)
- React-RCTNetwork (0.71.3):
- RCTTypeSafety (= 0.71.4)
- React-Codegen (= 0.71.4)
- React-Core/RCTImageHeaders (= 0.71.4)
- React-jsi (= 0.71.4)
- React-RCTNetwork (= 0.71.4)
- ReactCommon/turbomodule/core (= 0.71.4)
- React-RCTLinking (0.71.4):
- React-Codegen (= 0.71.4)
- React-Core/RCTLinkingHeaders (= 0.71.4)
- React-jsi (= 0.71.4)
- ReactCommon/turbomodule/core (= 0.71.4)
- React-RCTNetwork (0.71.4):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.71.3)
- React-Codegen (= 0.71.3)
- React-Core/RCTNetworkHeaders (= 0.71.3)
- React-jsi (= 0.71.3)
- ReactCommon/turbomodule/core (= 0.71.3)
- React-RCTSettings (0.71.3):
- RCTTypeSafety (= 0.71.4)
- React-Codegen (= 0.71.4)
- React-Core/RCTNetworkHeaders (= 0.71.4)
- React-jsi (= 0.71.4)
- ReactCommon/turbomodule/core (= 0.71.4)
- React-RCTSettings (0.71.4):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.71.3)
- React-Codegen (= 0.71.3)
- React-Core/RCTSettingsHeaders (= 0.71.3)
- React-jsi (= 0.71.3)
- ReactCommon/turbomodule/core (= 0.71.3)
- React-RCTText (0.71.3):
- React-Core/RCTTextHeaders (= 0.71.3)
- React-RCTVibration (0.71.3):
- RCTTypeSafety (= 0.71.4)
- React-Codegen (= 0.71.4)
- React-Core/RCTSettingsHeaders (= 0.71.4)
- React-jsi (= 0.71.4)
- ReactCommon/turbomodule/core (= 0.71.4)
- React-RCTText (0.71.4):
- React-Core/RCTTextHeaders (= 0.71.4)
- React-RCTVibration (0.71.4):
- RCT-Folly (= 2021.07.22.00)
- React-Codegen (= 0.71.3)
- React-Core/RCTVibrationHeaders (= 0.71.3)
- React-jsi (= 0.71.3)
- ReactCommon/turbomodule/core (= 0.71.3)
- React-runtimeexecutor (0.71.3):
- React-jsi (= 0.71.3)
- ReactCommon/turbomodule/bridging (0.71.3):
- React-Codegen (= 0.71.4)
- React-Core/RCTVibrationHeaders (= 0.71.4)
- React-jsi (= 0.71.4)
- ReactCommon/turbomodule/core (= 0.71.4)
- React-runtimeexecutor (0.71.4):
- React-jsi (= 0.71.4)
- ReactCommon/turbomodule/bridging (0.71.4):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-callinvoker (= 0.71.3)
- React-Core (= 0.71.3)
- React-cxxreact (= 0.71.3)
- React-jsi (= 0.71.3)
- React-logger (= 0.71.3)
- React-perflogger (= 0.71.3)
- ReactCommon/turbomodule/core (0.71.3):
- React-callinvoker (= 0.71.4)
- React-Core (= 0.71.4)
- React-cxxreact (= 0.71.4)
- React-jsi (= 0.71.4)
- React-logger (= 0.71.4)
- React-perflogger (= 0.71.4)
- ReactCommon/turbomodule/core (0.71.4):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- React-callinvoker (= 0.71.3)
- React-Core (= 0.71.3)
- React-cxxreact (= 0.71.3)
- React-jsi (= 0.71.3)
- React-logger (= 0.71.3)
- React-perflogger (= 0.71.3)
- React-callinvoker (= 0.71.4)
- React-Core (= 0.71.4)
- React-cxxreact (= 0.71.4)
- React-jsi (= 0.71.4)
- React-logger (= 0.71.4)
- React-perflogger (= 0.71.4)
- rn-fetch-blob (0.12.0):
- React-Core
- RNBackgroundFetch (4.1.9):
@ -627,7 +625,6 @@ DEPENDENCIES:
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-splash-screen (from `../node_modules/react-native-splash-screen`)
- react-native-version-number (from `../node_modules/react-native-version-number`)
- react-native-webview (from `../node_modules/react-native-webview`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
- React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
@ -770,8 +767,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-splash-screen"
react-native-version-number:
:path: "../node_modules/react-native-version-number"
react-native-webview:
:path: "../node_modules/react-native-webview"
React-perflogger:
:path: "../node_modules/react-native/ReactCommon/reactperflogger"
React-RCTActionSheet:
@ -846,38 +841,38 @@ SPEC CHECKSUMS:
EXJSONUtils: 48b1e764ac35160e6f54d21ab60d7d9501f3e473
EXManifests: 500666d48e8dd7ca5a482c9e729e4a7a6c34081b
EXMediaLibrary: 587cd8aad27a6fc8d7c38b950bc75bc1845a7480
Expo: 707f9b0039eacc6a1dce90c08c9e37b9c417bba2
Expo: 863488a600a4565698a79577117c70b170054d08
expo-dev-client: 7c1ef51516853465f4d448c14ddf365167d20361
expo-dev-launcher: 90de99d9e5d1a883d81355ca10e87c2f3c81d46e
expo-dev-menu: d4369e74d8d21a0ccdee35f7c732e7118b0fee16
expo-dev-menu: 4f54ef98df59d9d625677cb18ad4582de92b4a7d
expo-dev-menu-interface: 6c82ae323c4b8724dead4763ce3ff24a2108bdb1
ExpoImagePicker: 270dea232b3a072d981dd564e2cafc63a864edb1
ExpoKeepAwake: 69f5f627670d62318410392d03e0b5db0f85759a
ExpoLocalization: f26cd431ad9ea3533c5b08c4fabd879176a794bb
ExpoModulesCore: 397fc99e9d6c9dcc010f36d5802097c17b90424c
ExpoModulesCore: 6e0259511f4c4341b6b8357db393624df2280828
EXSplashScreen: cd7fb052dff5ba8311d5c2455ecbebffe1b7a8ca
EXUpdatesInterface: dd699d1930e28639dcbd70a402caea98e86364ca
FBLazyVector: 60195509584153283780abdac5569feffb8f08cc
FBReactNativeSpec: 9c191fb58d06dc05ab5559a5505fc32139e9e4a2
FBLazyVector: 446e84642979fff0ba57f3c804c2228a473aeac2
FBReactNativeSpec: 241709e132e3bf1526c1c4f00bc5384dd39dfba9
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
hermes-engine: 38bfe887e456b33b697187570a08de33969f5db7
hermes-engine: a1f157c49ea579c28b0296bda8530e980c45bdb3
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
RCTRequired: bec48f07daf7bcdc2655a0cde84e07d24d2a9e2a
RCTTypeSafety: 171394eebacf71e1cfad79dbfae7ee8fc16ca80a
React: d7433ccb6a8c36e4cbed59a73c0700fc83c3e98a
React-callinvoker: 15f165009bd22ae829b2b600e50bcc98076ce4b8
React-Codegen: b5910000eaf1e0c2f47d29be6f82f5f1264420d7
React-Core: b6f2f78d580a90b83fd7b0d1c6911c799f6eac82
React-CoreModules: e0cbc1a4f4f3f60e23c476fef7ab37be363ea8c1
React-cxxreact: c87f3f124b2117d00d410b35f16c2257e25e50fa
React-hermes: c64ca6bdf16a7069773103c9bedaf30ec90ab38f
React-jsi: 39729361645568e238081b3b3180fbad803f25a4
React-jsiexecutor: 515b703d23ffadeac7687bc2d12fb08b90f0aaa1
React-jsinspector: 9f7c9137605e72ca0343db4cea88006cb94856dd
React-logger: 957e5dc96d9dbffc6e0f15e0ee4d2b42829ff207
RCTRequired: 5a024fdf458fa8c0d82fc262e76f982d4dcdecdd
RCTTypeSafety: b6c253064466411c6810b45f66bc1e43ce0c54ba
React: 715292db5bd46989419445a5547954b25d2090f0
React-callinvoker: 105392d1179058585b564d35b4592fe1c46d6fba
React-Codegen: b75333b93d835afce84b73472927cccaef2c9f8c
React-Core: 88838ed1724c64905fc6c0811d752828a92e395b
React-CoreModules: cd238b4bb8dc8529ccc8b34ceae7267b04ce1882
React-cxxreact: 291bfab79d8098dc5ebab98f62e6bdfe81b3955a
React-hermes: b1e67e9a81c71745704950516f40ee804349641c
React-jsi: c9d5b563a6af6bb57034a82c2b0d39d0a7483bdc
React-jsiexecutor: d6b7fa9260aa3cb40afee0507e3bc1d17ecaa6f2
React-jsinspector: 1f51e775819199d3fe9410e69ee8d4c4161c7b06
React-logger: 0d58569ec51d30d1792c5e86a8e3b78d24b582c6
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
react-native-cameraroll: f3050460fe1708378698c16686bfaa5f34099be2
react-native-get-random-values: a6ea6a8a65dc93e96e24a11105b1a9c8cfe1d72a
@ -887,20 +882,19 @@ SPEC CHECKSUMS:
react-native-safe-area-context: 39c2d8be3328df5d437ac1700f4f3a4f75716acc
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f
react-native-webview: 994b9f8fbb504d6314dc40d83f94f27c6831b3bf
React-perflogger: af8a3d31546077f42d729b949925cc4549f14def
React-RCTActionSheet: 57cc5adfefbaaf0aae2cf7e10bccd746f2903673
React-RCTAnimation: 11c61e94da700c4dc915cf134513764d87fc5e2b
React-RCTAppDelegate: c3980adeaadcfd6cb495532e928b36ac6db3c14a
React-RCTBlob: ccc5049d742b41971141415ca86b83b201495695
React-RCTImage: 7a9226b0944f1e76e8e01e35a9245c2477cdbabb
React-RCTLinking: bbe8cc582046a9c04f79c235b73c93700263e8b4
React-RCTNetwork: fc2ca322159dc54e06508d4f5c3e934da63dc013
React-RCTSettings: f1e9db2cdf946426d3f2b210e4ff4ce0f0d842ef
React-RCTText: 1c41dd57e5d742b1396b4eeb251851ce7ff0fca1
React-RCTVibration: 5199a180d04873366a83855de55ac33ce60fe4d5
React-runtimeexecutor: 7bf0dafc7b727d93c8cb94eb00a9d3753c446c3e
ReactCommon: 6f65ea5b7d84deb9e386f670dd11ce499ded7b40
React-perflogger: 0bb0522a12e058f6eb69d888bc16f40c16c4b907
React-RCTActionSheet: bfd675a10f06a18728ea15d82082d48f228a213a
React-RCTAnimation: 2fa220b2052ec75b733112aca39143d34546a941
React-RCTAppDelegate: 8564f93c1d9274e95e3b0c746d08a87ff5a621b2
React-RCTBlob: d0336111f46301ae8aba2e161817e451aad72dd6
React-RCTImage: fec592c46edb7c12a9cde08780bdb4a688416c62
React-RCTLinking: 14eccac5d2a3b34b89dbfa29e8ef6219a153fe2d
React-RCTNetwork: 1fbce92e772e39ca3687a2ebb854501ff6226dd7
React-RCTSettings: 1abea36c9bb16d9979df6c4b42e2ea281b4bbcc5
React-RCTText: 15355c41561a9f43dfd23616d0a0dd40ba05ed61
React-RCTVibration: ad17efcfb2fa8f6bfd8ac0cf48d96668b8b28e0b
React-runtimeexecutor: 8fa50b38df6b992c76537993a2b0553d3b088004
ReactCommon: b49a4b00ca6d181ff74b17c12b2d59ac4add0bde
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
RNBackgroundFetch: 642777e4e76435773c149d565a043d66f1781237
RNCAsyncStorage: 09fc8595e6d6f6d5abf16b23a56b257d9c6b7c5b
@ -921,7 +915,7 @@ SPEC CHECKSUMS:
sovran-react-native: fd3dc8f1a4b14acdc4ad25fc6b4ac4f52a2a2a15
Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
Yoga: 5ed1699acbba8863755998a4245daa200ff3817b
Yoga: 79dd7410de6f8ad73a77c868d3d368843f0c93e0
PODFILE CHECKSUM: 5570c7b7d6ce7895f95d9db8a3a99b136a3f42c4

View File

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.10</string>
<string>1.11</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@ -1,86 +1,73 @@
import {AddressInfo} from 'net'
import os from 'os'
import net from 'net'
import path from 'path'
import * as crypto from '@atproto/crypto'
import PDSServer, {
Database as PDSDatabase,
MemoryBlobStore,
ServerConfig as PDSServerConfig,
} from '@atproto/pds'
import * as plc from '@atproto/plc'
import AtpAgent from '@atproto/api'
import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds'
import * as plc from '@did-plc/lib'
import {PlcServer, Database as PlcDatabase} from '@did-plc/server'
import {BskyAgent} from '@atproto/api'
const ADMIN_PASSWORD = 'admin-pass'
const SECOND = 1000
const MINUTE = SECOND * 60
const HOUR = MINUTE * 60
export interface TestUser {
email: string
did: string
declarationCid: string
handle: string
password: string
agent: AtpAgent
}
export interface TestUsers {
alice: TestUser
bob: TestUser
carla: TestUser
agent: BskyAgent
}
export interface TestPDS {
pdsUrl: string
users: TestUsers
mocker: Mocker
close: () => Promise<void>
}
// NOTE
// deterministic date generator
// we use this to ensure the mock dataset is always the same
// which is very useful when testing
function* dateGen() {
let start = 1657846031914
while (true) {
yield new Date(start).toISOString()
start += 1e3
}
}
export async function createServer(): Promise<TestPDS> {
const keypair = await crypto.EcdsaKeypair.create()
const repoSigningKey = await crypto.Secp256k1Keypair.create()
const plcRotationKey = await crypto.Secp256k1Keypair.create()
const port = await getPort()
// run plc server
const plcDb = plc.Database.memory()
await plcDb.migrateToLatestOrThrow()
const plcServer = plc.PlcServer.create({db: plcDb})
const plcDb = PlcDatabase.mock()
const plcServer = PlcServer.create({db: plcDb})
const plcListener = await plcServer.start()
const plcPort = (plcListener.address() as AddressInfo).port
const plcUrl = `http://localhost:${plcPort}`
const recoveryKey = (await crypto.EcdsaKeypair.create()).did()
const recoveryKey = (await crypto.Secp256k1Keypair.create()).did()
const plcClient = new plc.PlcClient(plcUrl)
const serverDid = await plcClient.createDid(
keypair,
recoveryKey,
'localhost',
'https://pds.public.url',
)
const plcClient = new plc.Client(plcUrl)
const serverDid = await plcClient.createDid({
signingKey: repoSigningKey.did(),
rotationKeys: [recoveryKey, plcRotationKey.did()],
handle: 'localhost',
pds: `http://localhost:${port}`,
signer: plcRotationKey,
})
const blobstoreLoc = path.join(os.tmpdir(), crypto.randomStr(5, 'base32'))
const cfg = new PDSServerConfig({
const cfg = new ServerConfig({
debugMode: true,
version: '0.0.0',
scheme: 'http',
hostname: 'localhost',
port,
serverDid,
recoveryKey,
adminPassword: 'admin-pass',
adminPassword: ADMIN_PASSWORD,
inviteRequired: false,
didPlcUrl: plcUrl,
jwtSecret: 'jwt-secret',
availableUserDomains: ['.test'],
appUrlPasswordReset: 'app://forgot-password',
emailNoReplyAddress: 'noreply@blueskyweb.xyz',
publicUrl: 'https://pds.public.url',
publicUrl: `http://localhost:${port}`,
imgUriSalt: '9dd04221f5755bce5f55f47464c27e1e',
imgUriKey:
'f23ecd142835025f42c3db2cf25dd813956c178392760256211f9d315f8ab4d8',
@ -88,22 +75,33 @@ export async function createServer(): Promise<TestPDS> {
blobstoreLocation: `${blobstoreLoc}/blobs`,
blobstoreTmp: `${blobstoreLoc}/tmp`,
maxSubscriptionBuffer: 200,
repoBackfillLimitMs: 1e3 * 60 * 60,
repoBackfillLimitMs: HOUR,
})
const db = PDSDatabase.memory()
const db =
cfg.dbPostgresUrl !== undefined
? Database.postgres({
url: cfg.dbPostgresUrl,
schema: cfg.dbPostgresSchema,
})
: Database.memory()
await db.migrateToLatestOrThrow()
const blobstore = new MemoryBlobStore()
const pds = PDSServer.create({db, blobstore, keypair, config: cfg})
const pdsServer = await pds.start()
const pdsPort = (pdsServer.address() as AddressInfo).port
const pdsUrl = `http://localhost:${pdsPort}`
const testUsers = await genMockData(pdsUrl)
const pds = PDS.create({
db,
blobstore,
repoSigningKey,
plcRotationKey,
config: cfg,
})
await pds.start()
const pdsUrl = `http://localhost:${port}`
return {
pdsUrl,
users: testUsers,
mocker: new Mocker(pdsUrl),
async close() {
await pds.destroy()
await plcServer.destroy()
@ -111,90 +109,93 @@ export async function createServer(): Promise<TestPDS> {
}
}
async function genMockData(pdsUrl: string): Promise<TestUsers> {
const date = dateGen()
class Mocker {
agent: BskyAgent
users: Record<string, TestUser> = {}
const agents = {
loggedout: new AtpAgent({service: pdsUrl}),
alice: new AtpAgent({service: pdsUrl}),
bob: new AtpAgent({service: pdsUrl}),
carla: new AtpAgent({service: pdsUrl}),
constructor(public service: string) {
this.agent = new BskyAgent({service})
}
const users: TestUser[] = [
{
email: 'alice@test.com',
did: '',
declarationCid: '',
handle: 'alice.test',
password: 'hunter2',
agent: agents.alice,
},
{
email: 'bob@test.com',
did: '',
declarationCid: '',
handle: 'bob.test',
password: 'hunter2',
agent: agents.bob,
},
{
email: 'carla@test.com',
did: '',
declarationCid: '',
handle: 'carla.test',
password: 'hunter2',
agent: agents.carla,
},
]
const alice = users[0]
const bob = users[1]
const carla = users[2]
let _i = 1
for (const user of users) {
const res = await agents.loggedout.api.com.atproto.account.create({
email: user.email,
handle: user.handle,
password: user.password,
// NOTE
// deterministic date generator
// we use this to ensure the mock dataset is always the same
// which is very useful when testing
*dateGen() {
let start = 1657846031914
while (true) {
yield new Date(start).toISOString()
start += 1e3
}
}
async createUser(name: string) {
const agent = new BskyAgent({service: this.agent.service})
const email = `fake${Object.keys(this.users).length + 1}@fake.com`
const res = await agent.createAccount({
email,
handle: name + '.test',
password: 'hunter2',
})
user.agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
const {data: profile} = await user.agent.api.app.bsky.actor.getProfile({
actor: user.handle,
})
user.did = res.data.did
user.declarationCid = profile.declaration.cid
await user.agent.api.app.bsky.actor.profile.create(
{did: user.did},
{
displayName: ucfirst(user.handle).slice(0, -5),
description: `Test user ${_i++}`,
},
)
this.users[name] = {
did: res.data.did,
email,
handle: name + '.test',
password: 'hunter2',
agent: agent,
}
}
// everybody follows everybody
const follow = async (author: TestUser, subject: TestUser) => {
await author.agent.api.app.bsky.graph.follow.create(
{did: author.did},
{
subject: {
did: subject.did,
declarationCid: subject.declarationCid,
},
createdAt: date.next().value || '',
},
)
async follow(a: string, b: string) {
await this.users[a].agent.follow(this.users[b].did)
}
await follow(alice, bob)
await follow(alice, carla)
await follow(bob, alice)
await follow(bob, carla)
await follow(carla, alice)
await follow(carla, bob)
return {alice, bob, carla}
async generateStandardGraph() {
await this.createUser('alice')
await this.createUser('bob')
await this.createUser('carla')
await this.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await this.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await this.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
await this.follow('alice', 'bob')
await this.follow('alice', 'carla')
await this.follow('bob', 'alice')
await this.follow('bob', 'carla')
await this.follow('carla', 'alice')
await this.follow('carla', 'bob')
}
}
function ucfirst(str: string): string {
return str.at(0)?.toUpperCase() + str.slice(1)
const checkAvailablePort = (port: number) =>
new Promise(resolve => {
const server = net.createServer()
server.unref()
server.on('error', () => resolve(false))
server.listen({port}, () => {
server.close(() => {
resolve(true)
})
})
})
async function getPort() {
for (let i = 3000; i < 65000; i++) {
if (await checkAvailablePort(i)) {
return i
}
}
throw new Error('Unable to find an available port')
}

View File

@ -1,3 +1,7 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const {getDefaultConfig} = require('expo/metro-config')
module.exports = getDefaultConfig(__dirname)
const cfg = getDefaultConfig(__dirname)
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
: cfg.resolver.sourceExts
module.exports = cfg

View File

@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.10.0",
"version": "1.11.0",
"private": true,
"scripts": {
"postinstall": "patch-package",
@ -15,12 +15,13 @@
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "jest --coverage",
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
"e2e": "detox test --configuration ios.sim.debug --take-screenshots all"
"e2e:mock-server": "ts-node __e2e__/mock-server.ts",
"e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build": "detox build -c ios.sim.debug",
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
},
"dependencies": {
"@atproto/api": "0.1.3",
"@atproto/lexicon": "^0.0.4",
"@atproto/xrpc": "^0.0.4",
"@atproto/api": "0.2.0",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@expo/webpack-config": "^18.0.1",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
@ -55,7 +56,7 @@
"await-lock": "^2.2.2",
"base64-js": "^1.5.1",
"email-validator": "^2.0.4",
"expo": "~48.0.0-beta.2",
"expo": "~48.0.9",
"expo-camera": "~13.2.1",
"expo-dev-client": "~2.1.1",
"expo-image-picker": "~14.1.1",
@ -63,6 +64,8 @@
"expo-media-library": "~15.2.3",
"expo-splash-screen": "~0.18.1",
"expo-status-bar": "~1.4.4",
"fast-text-encoding": "^1.0.6",
"graphemer": "^1.4.0",
"he": "^1.2.0",
"history": "^5.3.0",
"js-sha256": "^0.9.0",
@ -84,7 +87,7 @@
"react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0",
"react-native": "0.71.3",
"react-native": "0.71.4",
"react-native-appstate-hook": "^1.0.6",
"react-native-background-fetch": "^4.1.8",
"react-native-drawer-layout": "^3.2.0",
@ -109,19 +112,17 @@
"react-native-version-number": "^0.3.6",
"react-native-web": "^0.18.11",
"react-native-web-linear-gradient": "^1.1.2",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "11.26.0",
"react-native-youtube-iframe": "^2.2.2",
"rn-fetch-blob": "^0.12.0",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/pds": "^0.0.3",
"@atproto/pds": "^0.1.0",
"@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
"@did-plc/server": "^0.0.1",
"@react-native-community/eslint-config": "^3.0.0",
"@testing-library/jest-native": "^5.4.1",
"@testing-library/react-native": "^11.5.2",
@ -150,13 +151,14 @@
"eslint-plugin-ft-flow": "^2.0.3",
"html-webpack-plugin": "^5.5.0",
"jest": "^29.4.3",
"jest-expo": "^48.0.0-beta.2",
"jest-expo": "^48.0.2",
"jest-junit": "^15.0.0",
"metro-react-native-babel-preset": "^0.73.7",
"prettier": "^2.8.3",
"react-native-dotenv": "^3.3.1",
"react-scripts": "^5.0.1",
"react-test-renderer": "18.2.0",
"ts-node": "^10.9.1",
"typescript": "^4.4.4",
"url-loader": "^4.1.1",
"webpack": "^5.75.0",

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, {
const rt = new RichText(
{text: opts.rawText.trim()},
{
cleanNewlines: true,
}).text.trim()
},
)
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,12 +147,25 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
opts.extLink.localThumb.path,
encoding,
)
thumb = {
cid: thumbUploadRes.data.cid,
mimeType: encoding,
}
thumb = thumbUploadRes.data.blob
}
}
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: {
@ -154,11 +176,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
},
} 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,
return await store.agent.post({
text: rt.text,
facets: rt.facets,
reply,
embed,
entities,
createdAt: new Date().toISOString(),
},
)
})
} 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',
})
async toggleLike() {
if (this.post.viewer?.like) {
await this.rootStore.agent.deleteLike(this.post.viewer.like)
runInAction(() => {
if (wasDownvoted) {
this.post.downvoteCount--
}
if (wasUpvoted) {
this.post.upvoteCount--
this.post.likeCount = this.post.likeCount || 0
this.post.viewer = this.post.viewer || {}
this.post.likeCount--
this.post.viewer.like = undefined
})
} 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',
})
const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
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
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) {
runInAction(() => {
this.slices = nextSlicesModels.concat(
this.slices.filter(slice1 =>
nextSlicesModels.find(slice2 => slice1.uri === slice2.uri),
),
)
this.setHasNewLatest(false)
})
} else {
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',
})
async toggleLike() {
if (this.post.viewer?.like) {
await this.rootStore.agent.deleteLike(this.post.viewer.like)
runInAction(() => {
if (wasDownvoted) {
this.post.downvoteCount--
}
if (wasUpvoted) {
this.post.upvoteCount--
this.post.likeCount = this.post.likeCount || 0
this.post.viewer = this.post.viewer || {}
this.post.likeCount--
this.post.viewer.like = undefined
})
} 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',
})
const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
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
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,22 +117,23 @@ export class ProfileViewModel {
}
async updateProfile(
updates: AppBskyActorUpdateProfile.InputSchema,
updates: AppBskyActorProfile.Record,
newUserAvatar: PickedMedia | undefined | null,
newUserBanner: PickedMedia | undefined | null,
) {
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,
)
updates.avatar = {
cid: res.data.cid,
mimeType: newUserAvatar.mime,
}
existing.avatar = res.data.blob
} else if (newUserAvatar === null) {
updates.avatar = null
existing.avatar = undefined
}
if (newUserBanner) {
const res = await apilib.uploadBlob(
@ -155,26 +141,24 @@ export class ProfileViewModel {
newUserBanner.path,
newUserBanner.mime,
)
updates.banner = {
cid: res.data.cid,
mimeType: newUserBanner.mime,
}
existing.banner = res.data.blob
} else if (newUserBanner === null) {
updates.banner = null
existing.banner = undefined
}
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
}
}
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,8 +125,8 @@ export const PostThreadItem = observer(function PostThreadItem({
if (item._isHighlightedPost) {
return (
<>
<View
testID={`postThreadItem-by-${item.post.author.handle}`}
style={[
styles.outer,
styles.outerHighlighted,
@ -160,6 +160,7 @@ export const PostThreadItem = observer(function PostThreadItem({
</View>
<View style={s.flex1} />
<PostDropdownBtn
testID="postDropdownBtn"
style={styles.metaItem}
itemUri={itemUri}
itemCid={itemCid}
@ -191,10 +192,7 @@ export const PostThreadItem = observer(function PostThreadItem({
<View style={[s.pl10, s.pr10, s.pb10]}>
{item.richText?.text ? (
<View
style={[
styles.postTextContainer,
styles.postTextLargeContainer,
]}>
style={[styles.postTextContainer, styles.postTextLargeContainer]}>
<RichText
type="post-text-lg"
richText={item.richText}
@ -210,7 +208,7 @@ export const PostThreadItem = observer(function PostThreadItem({
style={styles.expandedInfoItem}
href={repostsHref}
title={repostsTitle}>
<Text type="lg" style={pal.textLight}>
<Text testID="repostCount" type="lg" style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{item.post.repostCount}
</Text>{' '}
@ -220,16 +218,16 @@ export const PostThreadItem = observer(function PostThreadItem({
) : (
<></>
)}
{item.post.upvoteCount ? (
{item.post.likeCount ? (
<Link
style={styles.expandedInfoItem}
href={upvotesHref}
title={upvotesTitle}>
<Text type="lg" style={pal.textLight}>
href={likesHref}
title={likesTitle}>
<Text testID="likeCount" type="lg" style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{item.post.upvoteCount}
{item.post.likeCount}
</Text>{' '}
{pluralize(item.post.upvoteCount, 'like')}
{pluralize(item.post.likeCount, 'like')}
</Text>
</Link>
) : (
@ -254,11 +252,11 @@ export const PostThreadItem = observer(function PostThreadItem({
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}
isReposted={!!item.post.viewer?.repost}
isLiked={!!item.post.viewer?.like}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onDeletePost={onDeletePost}
@ -266,12 +264,12 @@ export const PostThreadItem = observer(function PostThreadItem({
</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}

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