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: { testRunner: {
args: { args: {
$0: 'jest', $0: 'jest',
config: 'e2e/jest.config.js', config: '__e2e__/jest.config.js',
}, },
jest: { jest: {
setupTimeout: 120000, setupTimeout: 120000,
@ -12,15 +12,16 @@ module.exports = {
apps: { apps: {
'ios.debug': { 'ios.debug': {
type: 'ios.app', type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/app.app', binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/bluesky.app',
build: 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': { 'ios.release': {
type: 'ios.app', type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/app.app', binaryPath:
'ios/build/Build/Products/Release-iphonesimulator/bluesky.app',
build: 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': { 'android.debug': {
type: 'android.apk', type: 'android.apk',

View File

@ -18,6 +18,12 @@
- iOS: `yarn ios` - iOS: `yarn ios`
- Android: `yarn android` - Android: `yarn android`
- Web: `yarn web` - 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 - Tips
- `npx react-native info` Checks what has been installed. - `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) - 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} */ /** @type {import('@jest/types').Config.InitialOptions} */
module.exports = { module.exports = {
rootDir: '..', rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.test.js'], testMatch: ['<rootDir>/__e2e__/**/*.test.ts'],
testTimeout: 120000, testTimeout: 120000,
maxWorkers: 1, maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup', 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, getLikelyType,
} from '../../src/lib/link-meta/link-meta' } from '../../src/lib/link-meta/link-meta'
import {exampleComHtml} from './__mocks__/exampleComHtml' import {exampleComHtml} from './__mocks__/exampleComHtml'
import AtpAgent from '@atproto/api' import {BskyAgent} from '@atproto/api'
import {DEFAULT_SERVICE, RootStoreModel} from '../../src/state' import {DEFAULT_SERVICE, RootStoreModel} from '../../src/state'
describe('getLinkMeta', () => { describe('getLinkMeta', () => {
let rootStore: RootStoreModel let rootStore: RootStoreModel
beforeEach(() => { beforeEach(() => {
rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE})) rootStore = new RootStoreModel(new BskyAgent({service: DEFAULT_SERVICE}))
}) })
const inputs = [ const inputs = [

View File

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

View File

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

View File

@ -1,86 +1,73 @@
import {AddressInfo} from 'net' import {AddressInfo} from 'net'
import os from 'os' import os from 'os'
import net from 'net'
import path from 'path' import path from 'path'
import * as crypto from '@atproto/crypto' import * as crypto from '@atproto/crypto'
import PDSServer, { import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds'
Database as PDSDatabase, import * as plc from '@did-plc/lib'
MemoryBlobStore, import {PlcServer, Database as PlcDatabase} from '@did-plc/server'
ServerConfig as PDSServerConfig, import {BskyAgent} from '@atproto/api'
} from '@atproto/pds'
import * as plc from '@atproto/plc' const ADMIN_PASSWORD = 'admin-pass'
import AtpAgent from '@atproto/api' const SECOND = 1000
const MINUTE = SECOND * 60
const HOUR = MINUTE * 60
export interface TestUser { export interface TestUser {
email: string email: string
did: string did: string
declarationCid: string
handle: string handle: string
password: string password: string
agent: AtpAgent agent: BskyAgent
}
export interface TestUsers {
alice: TestUser
bob: TestUser
carla: TestUser
} }
export interface TestPDS { export interface TestPDS {
pdsUrl: string pdsUrl: string
users: TestUsers mocker: Mocker
close: () => Promise<void> 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> { 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 = PlcDatabase.mock()
const plcDb = plc.Database.memory()
await plcDb.migrateToLatestOrThrow() const plcServer = PlcServer.create({db: plcDb})
const plcServer = plc.PlcServer.create({db: plcDb})
const plcListener = await plcServer.start() const plcListener = await plcServer.start()
const plcPort = (plcListener.address() as AddressInfo).port const plcPort = (plcListener.address() as AddressInfo).port
const plcUrl = `http://localhost:${plcPort}` 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 plcClient = new plc.Client(plcUrl)
const serverDid = await plcClient.createDid( const serverDid = await plcClient.createDid({
keypair, signingKey: repoSigningKey.did(),
recoveryKey, rotationKeys: [recoveryKey, plcRotationKey.did()],
'localhost', handle: 'localhost',
'https://pds.public.url', pds: `http://localhost:${port}`,
) signer: plcRotationKey,
})
const blobstoreLoc = path.join(os.tmpdir(), crypto.randomStr(5, 'base32')) const blobstoreLoc = path.join(os.tmpdir(), crypto.randomStr(5, 'base32'))
const cfg = new PDSServerConfig({ const cfg = new ServerConfig({
debugMode: true, debugMode: true,
version: '0.0.0', version: '0.0.0',
scheme: 'http', scheme: 'http',
hostname: 'localhost', hostname: 'localhost',
port,
serverDid, serverDid,
recoveryKey, recoveryKey,
adminPassword: 'admin-pass', adminPassword: ADMIN_PASSWORD,
inviteRequired: false, inviteRequired: false,
didPlcUrl: plcUrl, didPlcUrl: plcUrl,
jwtSecret: 'jwt-secret', jwtSecret: 'jwt-secret',
availableUserDomains: ['.test'], availableUserDomains: ['.test'],
appUrlPasswordReset: 'app://forgot-password', appUrlPasswordReset: 'app://forgot-password',
emailNoReplyAddress: 'noreply@blueskyweb.xyz', emailNoReplyAddress: 'noreply@blueskyweb.xyz',
publicUrl: 'https://pds.public.url', publicUrl: `http://localhost:${port}`,
imgUriSalt: '9dd04221f5755bce5f55f47464c27e1e', imgUriSalt: '9dd04221f5755bce5f55f47464c27e1e',
imgUriKey: imgUriKey:
'f23ecd142835025f42c3db2cf25dd813956c178392760256211f9d315f8ab4d8', 'f23ecd142835025f42c3db2cf25dd813956c178392760256211f9d315f8ab4d8',
@ -88,22 +75,33 @@ export async function createServer(): Promise<TestPDS> {
blobstoreLocation: `${blobstoreLoc}/blobs`, blobstoreLocation: `${blobstoreLoc}/blobs`,
blobstoreTmp: `${blobstoreLoc}/tmp`, blobstoreTmp: `${blobstoreLoc}/tmp`,
maxSubscriptionBuffer: 200, 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() await db.migrateToLatestOrThrow()
const blobstore = new MemoryBlobStore() const blobstore = new MemoryBlobStore()
const pds = PDSServer.create({db, blobstore, keypair, config: cfg}) const pds = PDS.create({
const pdsServer = await pds.start() db,
const pdsPort = (pdsServer.address() as AddressInfo).port blobstore,
const pdsUrl = `http://localhost:${pdsPort}` repoSigningKey,
const testUsers = await genMockData(pdsUrl) plcRotationKey,
config: cfg,
})
await pds.start()
const pdsUrl = `http://localhost:${port}`
return { return {
pdsUrl, pdsUrl,
users: testUsers, mocker: new Mocker(pdsUrl),
async close() { async close() {
await pds.destroy() await pds.destroy()
await plcServer.destroy() await plcServer.destroy()
@ -111,90 +109,93 @@ export async function createServer(): Promise<TestPDS> {
} }
} }
async function genMockData(pdsUrl: string): Promise<TestUsers> { class Mocker {
const date = dateGen() agent: BskyAgent
users: Record<string, TestUser> = {}
const agents = { constructor(public service: string) {
loggedout: new AtpAgent({service: pdsUrl}), this.agent = new BskyAgent({service})
alice: new AtpAgent({service: pdsUrl}),
bob: new AtpAgent({service: pdsUrl}),
carla: new AtpAgent({service: pdsUrl}),
} }
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 // NOTE
for (const user of users) { // deterministic date generator
const res = await agents.loggedout.api.com.atproto.account.create({ // we use this to ensure the mock dataset is always the same
email: user.email, // which is very useful when testing
handle: user.handle, *dateGen() {
password: user.password, 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}`) this.users[name] = {
const {data: profile} = await user.agent.api.app.bsky.actor.getProfile({ did: res.data.did,
actor: user.handle, email,
}) handle: name + '.test',
user.did = res.data.did password: 'hunter2',
user.declarationCid = profile.declaration.cid agent: agent,
await user.agent.api.app.bsky.actor.profile.create( }
{did: user.did},
{
displayName: ucfirst(user.handle).slice(0, -5),
description: `Test user ${_i++}`,
},
)
} }
// everybody follows everybody async follow(a: string, b: string) {
const follow = async (author: TestUser, subject: TestUser) => { await this.users[a].agent.follow(this.users[b].did)
await author.agent.api.app.bsky.graph.follow.create(
{did: author.did},
{
subject: {
did: subject.did,
declarationCid: subject.declarationCid,
},
createdAt: date.next().value || '',
},
)
} }
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 { const checkAvailablePort = (port: number) =>
return str.at(0)?.toUpperCase() + str.slice(1) 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 // Learn more https://docs.expo.io/guides/customizing-metro
const {getDefaultConfig} = require('expo/metro-config') 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", "name": "bsky.app",
"version": "1.10.0", "version": "1.11.0",
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "patch-package", "postinstall": "patch-package",
@ -15,12 +15,13 @@
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit", "test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "jest --coverage", "test-coverage": "jest --coverage",
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx", "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": { "dependencies": {
"@atproto/api": "0.1.3", "@atproto/api": "0.2.0",
"@atproto/lexicon": "^0.0.4",
"@atproto/xrpc": "^0.0.4",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@expo/webpack-config": "^18.0.1", "@expo/webpack-config": "^18.0.1",
"@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/fontawesome-svg-core": "^6.1.1",
@ -55,7 +56,7 @@
"await-lock": "^2.2.2", "await-lock": "^2.2.2",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"email-validator": "^2.0.4", "email-validator": "^2.0.4",
"expo": "~48.0.0-beta.2", "expo": "~48.0.9",
"expo-camera": "~13.2.1", "expo-camera": "~13.2.1",
"expo-dev-client": "~2.1.1", "expo-dev-client": "~2.1.1",
"expo-image-picker": "~14.1.1", "expo-image-picker": "~14.1.1",
@ -63,6 +64,8 @@
"expo-media-library": "~15.2.3", "expo-media-library": "~15.2.3",
"expo-splash-screen": "~0.18.1", "expo-splash-screen": "~0.18.1",
"expo-status-bar": "~1.4.4", "expo-status-bar": "~1.4.4",
"fast-text-encoding": "^1.0.6",
"graphemer": "^1.4.0",
"he": "^1.2.0", "he": "^1.2.0",
"history": "^5.3.0", "history": "^5.3.0",
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
@ -84,7 +87,7 @@
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0", "react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.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-appstate-hook": "^1.0.6",
"react-native-background-fetch": "^4.1.8", "react-native-background-fetch": "^4.1.8",
"react-native-drawer-layout": "^3.2.0", "react-native-drawer-layout": "^3.2.0",
@ -109,19 +112,17 @@
"react-native-version-number": "^0.3.6", "react-native-version-number": "^0.3.6",
"react-native-web": "^0.18.11", "react-native-web": "^0.18.11",
"react-native-web-linear-gradient": "^1.1.2", "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", "rn-fetch-blob": "^0.12.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tlds": "^1.234.0", "tlds": "^1.234.0",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@atproto/pds": "^0.0.3", "@atproto/pds": "^0.1.0",
"@babel/core": "^7.20.0", "@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.0", "@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",
"@did-plc/server": "^0.0.1",
"@react-native-community/eslint-config": "^3.0.0", "@react-native-community/eslint-config": "^3.0.0",
"@testing-library/jest-native": "^5.4.1", "@testing-library/jest-native": "^5.4.1",
"@testing-library/react-native": "^11.5.2", "@testing-library/react-native": "^11.5.2",
@ -150,13 +151,14 @@
"eslint-plugin-ft-flow": "^2.0.3", "eslint-plugin-ft-flow": "^2.0.3",
"html-webpack-plugin": "^5.5.0", "html-webpack-plugin": "^5.5.0",
"jest": "^29.4.3", "jest": "^29.4.3",
"jest-expo": "^48.0.0-beta.2", "jest-expo": "^48.0.2",
"jest-junit": "^15.0.0", "jest-junit": "^15.0.0",
"metro-react-native-babel-preset": "^0.73.7", "metro-react-native-babel-preset": "^0.73.7",
"prettier": "^2.8.3", "prettier": "^2.8.3",
"react-native-dotenv": "^3.3.1", "react-native-dotenv": "^3.3.1",
"react-scripts": "^5.0.1", "react-scripts": "^5.0.1",
"react-test-renderer": "18.2.0", "react-test-renderer": "18.2.0",
"ts-node": "^10.9.1",
"typescript": "^4.4.4", "typescript": "^4.4.4",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^5.75.0", "webpack": "^5.75.0",

View File

@ -29,7 +29,6 @@ const App = observer(() => {
analytics.init(store) analytics.init(store)
notifee.init(store) notifee.init(store)
SplashScreen.hide() SplashScreen.hide()
store.hackCheckIfUpgradeNeeded()
Linking.getInitialURL().then((url: string | null) => { Linking.getInitialURL().then((url: string | null) => {
if (url) { if (url) {
handleLink(url) handleLink(url)

View File

@ -31,7 +31,7 @@ import {ProfileScreen} from './view/screens/Profile'
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from './view/screens/ProfileFollows' import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
import {PostThreadScreen} from './view/screens/PostThread' 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 {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {DebugScreen} from './view/screens/Debug' import {DebugScreen} from './view/screens/Debug'
import {LogScreen} from './view/screens/Log' import {LogScreen} from './view/screens/Log'
@ -62,7 +62,7 @@ function commonScreens(Stack: typeof HomeTab) {
/> />
<Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} /> <Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} />
<Stack.Screen name="PostThread" component={PostThreadScreen} /> <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="PostRepostedBy" component={PostRepostedByScreen} />
<Stack.Screen name="Debug" component={DebugScreen} /> <Stack.Screen name="Debug" component={DebugScreen} />
<Stack.Screen name="Log" component={LogScreen} /> <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' import RNFS from 'react-native-fs'
const GET_TIMEOUT = 15e3 // 15s const GET_TIMEOUT = 15e3 // 15s
const POST_TIMEOUT = 60e3 // 60s const POST_TIMEOUT = 60e3 // 60s
export function doPolyfill() { export function doPolyfill() {
AtpAgent.configure({fetch: fetchHandler}) BskyAgent.configure({fetch: fetchHandler})
} }
interface FetchHandlerResponse { interface FetchHandlerResponse {
@ -22,7 +22,7 @@ async function fetchHandler(
): Promise<FetchHandlerResponse> { ): Promise<FetchHandlerResponse> {
const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type'] const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type']
if (reqMimeType && reqMimeType.startsWith('application/json')) { if (reqMimeType && reqMimeType.startsWith('application/json')) {
reqBody = JSON.stringify(reqBody) reqBody = stringifyLex(reqBody)
} else if ( } else if (
typeof reqBody === 'string' && typeof reqBody === 'string' &&
(reqBody.startsWith('/') || reqBody.startsWith('file:')) (reqBody.startsWith('/') || reqBody.startsWith('file:'))
@ -65,7 +65,7 @@ async function fetchHandler(
let resBody let resBody
if (resMimeType) { if (resMimeType) {
if (resMimeType.startsWith('application/json')) { if (resMimeType.startsWith('application/json')) {
resBody = await res.json() resBody = jsonToLex(await res.json())
} else if (resMimeType.startsWith('text/')) { } else if (resMimeType.startsWith('text/')) {
resBody = await res.text() resBody = await res.text()
} else { } else {

View File

@ -1,4 +1,3 @@
export function doPolyfill() { export function doPolyfill() {
// TODO needed? native fetch may work fine -prf // no polyfill is needed on web
// AtpApi.xrpc.fetch = fetchHandler
} }

View File

@ -1,9 +1,9 @@
import {RootStoreModel} from 'state/index' import {RootStoreModel} from 'state/index'
import { import {
AppBskyFeedFeedViewPost, AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed, AppBskyFeedGetAuthorFeed as GetAuthorFeed,
} from '@atproto/api' } from '@atproto/api'
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost type ReasonRepost = AppBskyFeedDefs.ReasonRepost
async function getMultipleAuthorsPosts( async function getMultipleAuthorsPosts(
rootStore: RootStoreModel, rootStore: RootStoreModel,
@ -12,12 +12,12 @@ async function getMultipleAuthorsPosts(
limit: number = 10, limit: number = 10,
) { ) {
const responses = await Promise.all( const responses = await Promise.all(
authors.map((author, index) => authors.map((actor, index) =>
rootStore.api.app.bsky.feed rootStore.agent
.getAuthorFeed({ .getAuthorFeed({
author, actor,
limit, limit,
before: cursor ? cursor.split(',')[index] : undefined, cursor: cursor ? cursor.split(',')[index] : undefined,
}) })
.catch(_err => ({success: false, headers: {}, data: {feed: []}})), .catch(_err => ({success: false, headers: {}, data: {feed: []}})),
), ),
@ -29,14 +29,14 @@ function mergePosts(
responses: GetAuthorFeed.Response[], responses: GetAuthorFeed.Response[],
{repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean}, {repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean},
) { ) {
let posts: AppBskyFeedFeedViewPost.Main[] = [] let posts: AppBskyFeedDefs.FeedViewPost[] = []
if (bestOfOnly) { if (bestOfOnly) {
for (const res of responses) { for (const res of responses) {
if (res.success) { 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( res.data.feed = res.data.feed.reduce(
(acc: AppBskyFeedFeedViewPost.Main[], v) => { (acc: AppBskyFeedDefs.FeedViewPost[], v) => {
if ( if (
!acc?.[0] && !acc?.[0] &&
!v.reason && !v.reason &&
@ -49,7 +49,7 @@ function mergePosts(
acc && acc &&
!v.reason && !v.reason &&
!v.reply && !v.reply &&
v.post.upvoteCount > acc[0]?.post.upvoteCount && (v.post.likeCount || 0) > (acc[0]?.post.likeCount || 0) &&
isRecentEnough(v.post.indexedAt) isRecentEnough(v.post.indexedAt)
) { ) {
return [v] return [v]
@ -92,7 +92,7 @@ function mergePosts(
return posts return posts
} }
function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean { function isARepostOfSomeoneElse(post: AppBskyFeedDefs.FeedViewPost): boolean {
return ( return (
post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' && post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' &&
post.post.author.did !== (post.reason as ReasonRepost).by.did 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' import lande from 'lande'
type FeedViewPost = AppBskyFeedFeedViewPost.Main import {hasProp} from 'lib/type-guards'
import {hasProp} from '@atproto/lexicon'
import {LANGUAGES_MAP_CODE2} from '../../locale/languages' import {LANGUAGES_MAP_CODE2} from '../../locale/languages'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
export type FeedTunerFn = ( export type FeedTunerFn = (
tuner: FeedTuner, tuner: FeedTuner,
@ -174,7 +174,7 @@ export class FeedTuner {
} }
const item = slices[i].rootItem const item = slices[i].rootItem
const isRepost = Boolean(item.reason) const isRepost = Boolean(item.reason)
if (!isRepost && item.post.upvoteCount < 2) { if (!isRepost && (item.post.likeCount || 0) < 2) {
slices.splice(i, 1) slices.splice(i, 1)
} }
} }

View File

@ -1,16 +1,16 @@
import { import {
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedExternal, AppBskyEmbedExternal,
ComAtprotoBlobUpload,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
ComAtprotoRepoUploadBlob,
RichText,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '../../third-party/uri' import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from 'state/models/root-store' import {RootStoreModel} from 'state/models/root-store'
import {extractEntities} from 'lib/strings/rich-text-detection'
import {isNetworkError} from 'lib/strings/errors' import {isNetworkError} from 'lib/strings/errors'
import {LinkMeta} from '../link-meta/link-meta' import {LinkMeta} from '../link-meta/link-meta'
import {Image} from '../media/manip' import {Image} from '../media/manip'
import {RichText} from '../strings/rich-text'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
export interface ExternalEmbedDraft { export interface ExternalEmbedDraft {
@ -27,7 +27,7 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
if (didOrHandle.startsWith('did:')) { if (didOrHandle.startsWith('did:')) {
return didOrHandle return didOrHandle
} }
const res = await store.api.com.atproto.handle.resolve({ const res = await store.agent.resolveHandle({
handle: didOrHandle, handle: didOrHandle,
}) })
return res.data.did return res.data.did
@ -37,15 +37,15 @@ export async function uploadBlob(
store: RootStoreModel, store: RootStoreModel,
blob: string, blob: string,
encoding: string, encoding: string,
): Promise<ComAtprotoBlobUpload.Response> { ): Promise<ComAtprotoRepoUploadBlob.Response> {
if (isWeb) { if (isWeb) {
// `blob` should be a data uri // `blob` should be a data uri
return store.api.com.atproto.blob.upload(convertDataURIToUint8Array(blob), { return store.agent.uploadBlob(convertDataURIToUint8Array(blob), {
encoding, encoding,
}) })
} else { } else {
// `blob` should be a path to a file in the local FS // `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 blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding}, {encoding},
) )
@ -70,22 +70,18 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
| AppBskyEmbedImages.Main | AppBskyEmbedImages.Main
| AppBskyEmbedExternal.Main | AppBskyEmbedExternal.Main
| AppBskyEmbedRecord.Main | AppBskyEmbedRecord.Main
| AppBskyEmbedRecordWithMedia.Main
| undefined | undefined
let reply let reply
const text = new RichText(opts.rawText, undefined, { const rt = new RichText(
cleanNewlines: true, {text: opts.rawText.trim()},
}).text.trim() {
cleanNewlines: true,
},
)
opts.onStateChange?.('Processing...') opts.onStateChange?.('Processing...')
const entities = extractEntities(text, opts.knownHandles) await rt.detectFacets(store.agent)
if (entities) {
for (const ent of entities) {
if (ent.type === 'mention') {
const prof = await store.profiles.getProfile(ent.value)
ent.value = prof.data.did
}
}
}
if (opts.quote) { if (opts.quote) {
embed = { embed = {
@ -95,24 +91,37 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
cid: opts.quote.cid, cid: opts.quote.cid,
}, },
} as AppBskyEmbedRecord.Main } as AppBskyEmbedRecord.Main
} else if (opts.images?.length) { }
embed = {
$type: 'app.bsky.embed.images', if (opts.images?.length) {
images: [], const images: AppBskyEmbedImages.Image[] = []
} as AppBskyEmbedImages.Main
let i = 1
for (const image of opts.images) { 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') const res = await uploadBlob(store, image, 'image/jpeg')
embed.images.push({ images.push({
image: { image: res.data.blob,
cid: res.data.cid,
mimeType: 'image/jpeg',
},
alt: '', // TODO supply alt text 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 let thumb
if (opts.extLink.localThumb) { if (opts.extLink.localThumb) {
opts.onStateChange?.('Uploading link thumbnail...') opts.onStateChange?.('Uploading link thumbnail...')
@ -138,27 +147,41 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
opts.extLink.localThumb.path, opts.extLink.localThumb.path,
encoding, encoding,
) )
thumb = { thumb = thumbUploadRes.data.blob
cid: thumbUploadRes.data.cid,
mimeType: encoding,
}
} }
} }
embed = {
$type: 'app.bsky.embed.external', if (opts.quote) {
external: { embed = {
uri: opts.extLink.uri, $type: 'app.bsky.embed.recordWithMedia',
title: opts.extLink.meta?.title || '', record: embed,
description: opts.extLink.meta?.description || '', media: {
thumb, $type: 'app.bsky.embed.external',
}, external: {
} as AppBskyEmbedExternal.Main uri: opts.extLink.uri,
title: opts.extLink.meta?.title || '',
description: opts.extLink.meta?.description || '',
thumb,
},
} as AppBskyEmbedExternal.Main,
} as AppBskyEmbedRecordWithMedia.Main
} else {
embed = {
$type: 'app.bsky.embed.external',
external: {
uri: opts.extLink.uri,
title: opts.extLink.meta?.title || '',
description: opts.extLink.meta?.description || '',
thumb,
},
} as AppBskyEmbedExternal.Main
}
} }
if (opts.replyTo) { if (opts.replyTo) {
const replyToUrip = new AtUri(opts.replyTo) const replyToUrip = new AtUri(opts.replyTo)
const parentPost = await store.api.app.bsky.feed.post.get({ const parentPost = await store.agent.getPost({
user: replyToUrip.host, repo: replyToUrip.host,
rkey: replyToUrip.rkey, rkey: replyToUrip.rkey,
}) })
if (parentPost) { if (parentPost) {
@ -175,16 +198,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
try { try {
opts.onStateChange?.('Posting...') opts.onStateChange?.('Posting...')
return await store.api.app.bsky.feed.post.create( return await store.agent.post({
{did: store.me.did || ''}, text: rt.text,
{ facets: rt.facets,
text, reply,
reply, embed,
embed, })
entities,
createdAt: new Date().toISOString(),
},
)
} catch (e: any) { } catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`) console.error(`Failed to create post: ${e.toString()}`)
if (isNetworkError(e)) { 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 // 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 author = notif.author.displayName || notif.author.handle
let title: string let title: string
let body: string = '' let body: string = ''
if (notif.isUpvote) { if (notif.isLike) {
title = `${author} liked your post` title = `${author} liked your post`
body = notif.additionalPost?.thread?.postRecord?.text || '' body = notif.additionalPost?.thread?.postRecord?.text || ''
} else if (notif.isRepost) { } else if (notif.isRepost) {
@ -65,7 +65,7 @@ export function displayNotificationFromModel(
} }
let image let image
if ( if (
AppBskyEmbedImages.isPresented(notif.additionalPost?.thread?.post.embed) && AppBskyEmbedImages.isView(notif.additionalPost?.thread?.post.embed) &&
notif.additionalPost?.thread?.post.embed.images[0]?.thumb notif.additionalPost?.thread?.post.embed.images[0]?.thumb
) { ) {
image = 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} ProfileFollowers: {name: string}
ProfileFollows: {name: string} ProfileFollows: {name: string}
PostThread: {name: string; rkey: string} PostThread: {name: string; rkey: string}
PostUpvotedBy: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string}
PostRepostedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string}
Debug: undefined Debug: undefined
Log: undefined Log: undefined

View File

@ -1,64 +1,5 @@
import {AppBskyFeedPost} from '@atproto/api'
type Entity = AppBskyFeedPost.Entity
import {isValidDomain} from './url-helpers' 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 { interface DetectedLink {
link: string 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}, borderBottom1: {borderBottomWidth: 1},
borderLeft1: {borderLeftWidth: 1}, borderLeft1: {borderLeftWidth: 1},
hidden: {display: 'none'}, hidden: {display: 'none'},
dimmed: {opacity: 0.5},
// font weights // font weights
fw600: {fontWeight: '600'}, fw600: {fontWeight: '600'},

View File

@ -1,3 +1,5 @@
import 'fast-text-encoding'
import Graphemer from 'graphemer'
export {} export {}
/** /**
@ -48,3 +50,18 @@ globalThis.atob = (str: string): string => {
} }
return result 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 // @ts-ignore whatever typescript wants to complain about here, I dont care about -prf
window.setImmediate = (cb: () => void) => setTimeout(cb, 0) 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', ProfileFollowers: '/profile/:name/followers',
ProfileFollows: '/profile/:name/follows', ProfileFollows: '/profile/:name/follows',
PostThread: '/profile/:name/post/:rkey', 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', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
Debug: '/sys/debug', Debug: '/sys/debug',
Log: '/sys/log', Log: '/sys/log',

View File

@ -1,6 +1,6 @@
import {autorun} from 'mobx' import {autorun} from 'mobx'
import {AppState, Platform} from 'react-native' import {AppState, Platform} from 'react-native'
import {AtpAgent} from '@atproto/api' import {BskyAgent} from '@atproto/api'
import {RootStoreModel} from './models/root-store' import {RootStoreModel} from './models/root-store'
import * as apiPolyfill from 'lib/api/api-polyfill' import * as apiPolyfill from 'lib/api/api-polyfill'
import * as storage from 'lib/storage' import * as storage from 'lib/storage'
@ -19,7 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
apiPolyfill.doPolyfill() apiPolyfill.doPolyfill()
rootStore = new RootStoreModel(new AtpAgent({service: serviceUri})) rootStore = new RootStoreModel(new BskyAgent({service: serviceUri}))
try { try {
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
rootStore.log.debug('Initial hydrate', {hasSession: !!data.session}) rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})

View File

@ -3,7 +3,7 @@ import {Dim} from 'lib/media/manip'
export class ImageSizesCache { export class ImageSizesCache {
sizes: Map<string, Dim> = new Map() sizes: Map<string, Dim> = new Map()
private activeRequests: Map<string, Promise<Dim>> = new Map() activeRequests: Map<string, Promise<Dim>> = new Map()
constructor() {} constructor() {}

View File

@ -1,15 +1,12 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' import {FollowRecord, AppBskyActorDefs} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
const CACHE_TTL = 1000 * 60 * 60 // hourly const CACHE_TTL = 1000 * 60 * 60 // hourly
type FollowsListResponse = Awaited<ReturnType<FollowRecord['list']>> type FollowsListResponse = Awaited<ReturnType<FollowRecord['list']>>
type FollowsListResponseRecord = FollowsListResponse['records'][0] type FollowsListResponseRecord = FollowsListResponse['records'][0]
type Profile = type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
| AppBskyActorProfile.ViewBasic
| AppBskyActorProfile.View
| AppBskyActorRef.WithInfo
/** /**
* This model is used to maintain a synced local cache of the user's * This model is used to maintain a synced local cache of the user's
@ -53,21 +50,21 @@ export class MyFollowsCache {
fetch = bundleAsync(async () => { fetch = bundleAsync(async () => {
this.rootStore.log.debug('MyFollowsModel:fetch running full fetch') this.rootStore.log.debug('MyFollowsModel:fetch running full fetch')
let before let rkeyStart
let records: FollowsListResponseRecord[] = [] let records: FollowsListResponseRecord[] = []
do { do {
const res: FollowsListResponse = const res: FollowsListResponse =
await this.rootStore.api.app.bsky.graph.follow.list({ await this.rootStore.agent.app.bsky.graph.follow.list({
user: this.rootStore.me.did, repo: this.rootStore.me.did,
before, rkeyStart,
}) })
records = records.concat(res.records) records = records.concat(res.records)
before = res.cursor rkeyStart = res.cursor
} while (typeof before !== 'undefined') } while (typeof rkeyStart !== 'undefined')
runInAction(() => { runInAction(() => {
this.followDidToRecordMap = {} this.followDidToRecordMap = {}
for (const record of records) { 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.lastSync = Date.now()
this.myDid = this.rootStore.me.did 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 {makeAutoObservable, runInAction} from 'mobx'
import sampleSize from 'lodash.samplesize' import sampleSize from 'lodash.samplesize'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
export type RefWithInfoAndFollowers = AppBskyActorRef.WithInfo & { export type RefWithInfoAndFollowers = AppBskyActorDefs.ProfileViewBasic & {
followers: AppBskyActorProfile.View[] followers: AppBskyActorDefs.ProfileView[]
} }
export type ProfileViewFollows = AppBskyActorProfile.View & { export type ProfileViewFollows = AppBskyActorDefs.ProfileView & {
follows: AppBskyActorRef.WithInfo[] follows: AppBskyActorDefs.ProfileViewBasic[]
} }
export class FoafsModel { export class FoafsModel {
@ -51,14 +51,14 @@ export class FoafsModel {
this.popular.length = 0 this.popular.length = 0
// fetch their profiles // fetch their profiles
const profiles = await this.rootStore.api.app.bsky.actor.getProfiles({ const profiles = await this.rootStore.agent.getProfiles({
actors: this.sources, actors: this.sources,
}) })
// fetch their follows // fetch their follows
const results = await Promise.allSettled( const results = await Promise.allSettled(
this.sources.map(source => 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 {makeAutoObservable, runInAction} from 'mobx'
import {AppBskyActorProfile as Profile} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import shuffle from 'lodash.shuffle' import shuffle from 'lodash.shuffle'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
@ -8,7 +8,9 @@ import {SUGGESTED_FOLLOWS} from 'lib/constants'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
export type SuggestedActor = Profile.ViewBasic | Profile.View export type SuggestedActor =
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileView
export class SuggestedActorsModel { export class SuggestedActorsModel {
// state // state
@ -20,7 +22,7 @@ export class SuggestedActorsModel {
hasMore = true hasMore = true
loadMoreCursor?: string loadMoreCursor?: string
private hardCodedSuggestions: SuggestedActor[] | undefined hardCodedSuggestions: SuggestedActor[] | undefined
// data // data
suggestions: SuggestedActor[] = [] suggestions: SuggestedActor[] = []
@ -82,7 +84,7 @@ export class SuggestedActorsModel {
this.loadMoreCursor = undefined this.loadMoreCursor = undefined
} else { } else {
// pull from the PDS' algo // 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, limit: this.pageSize,
cursor: this.loadMoreCursor, cursor: this.loadMoreCursor,
}) })
@ -104,7 +106,7 @@ export class SuggestedActorsModel {
} }
}) })
private async fetchHardcodedSuggestions() { async fetchHardcodedSuggestions() {
if (this.hardCodedSuggestions) { if (this.hardCodedSuggestions) {
return return
} }
@ -118,9 +120,9 @@ export class SuggestedActorsModel {
] ]
// fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`) // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)
let profiles: Profile.View[] = [] let profiles: AppBskyActorDefs.ProfileView[] = []
do { do {
const res = await this.rootStore.api.app.bsky.actor.getProfiles({ const res = await this.rootStore.agent.getProfiles({
actors: actors.splice(0, 25), actors: actors.splice(0, 25),
}) })
profiles = profiles.concat(res.data.profiles) profiles = profiles.concat(res.data.profiles)
@ -152,13 +154,13 @@ export class SuggestedActorsModel {
// state transitions // state transitions
// = // =
private _xLoading(isRefreshing = false) { _xLoading(isRefreshing = false) {
this.isLoading = true this.isLoading = true
this.isRefreshing = isRefreshing this.isRefreshing = isRefreshing
this.error = '' this.error = ''
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true

View File

@ -1,32 +1,29 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import { import {
AppBskyFeedGetTimeline as GetTimeline, AppBskyFeedGetTimeline as GetTimeline,
AppBskyFeedFeedViewPost, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyFeedGetAuthorFeed as GetAuthorFeed, AppBskyFeedGetAuthorFeed as GetAuthorFeed,
RichText,
} from '@atproto/api' } from '@atproto/api'
import AwaitLock from 'await-lock' import AwaitLock from 'await-lock'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import sampleSize from 'lodash.samplesize' 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 {RootStoreModel} from './root-store'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {RichText} from 'lib/strings/rich-text'
import {SUGGESTED_FOLLOWS} from 'lib/constants' import {SUGGESTED_FOLLOWS} from 'lib/constants'
import { import {
getCombinedCursors, getCombinedCursors,
getMultipleAuthorsPosts, getMultipleAuthorsPosts,
mergePosts, mergePosts,
} from 'lib/api/build-suggested-posts' } from 'lib/api/build-suggested-posts'
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' 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 let _idCounter = 0
export class FeedItemModel { export class FeedItemModel {
@ -51,11 +48,7 @@ export class FeedItemModel {
const valid = AppBskyFeedPost.validateRecord(this.post.record) const valid = AppBskyFeedPost.validateRecord(this.post.record)
if (valid.success) { if (valid.success) {
this.postRecord = this.post.record this.postRecord = this.post.record
this.richText = new RichText( this.richText = new RichText(this.postRecord, {cleanNewlines: true})
this.postRecord.text,
this.postRecord.entities,
{cleanNewlines: true},
)
} else { } else {
rootStore.log.warn( rootStore.log.warn(
'Received an invalid app.bsky.feed.post record', 'Received an invalid app.bsky.feed.post record',
@ -82,7 +75,7 @@ export class FeedItemModel {
copyMetrics(v: FeedViewPost) { copyMetrics(v: FeedViewPost) {
this.post.replyCount = v.post.replyCount this.post.replyCount = v.post.replyCount
this.post.repostCount = v.post.repostCount this.post.repostCount = v.post.repostCount
this.post.upvoteCount = v.post.upvoteCount this.post.likeCount = v.post.likeCount
this.post.viewer = v.post.viewer this.post.viewer = v.post.viewer
} }
@ -92,68 +85,43 @@ export class FeedItemModel {
} }
} }
async toggleUpvote() { async toggleLike() {
const wasUpvoted = !!this.post.viewer.upvote if (this.post.viewer?.like) {
const wasDownvoted = !!this.post.viewer.downvote await this.rootStore.agent.deleteLike(this.post.viewer.like)
const res = await this.rootStore.api.app.bsky.feed.setVote({ runInAction(() => {
subject: { this.post.likeCount = this.post.likeCount || 0
uri: this.post.uri, this.post.viewer = this.post.viewer || {}
cid: this.post.cid, this.post.likeCount--
}, this.post.viewer.like = undefined
direction: wasUpvoted ? 'none' : 'up', })
}) } else {
runInAction(() => { const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
if (wasDownvoted) { runInAction(() => {
this.post.downvoteCount-- this.post.likeCount = this.post.likeCount || 0
} this.post.viewer = this.post.viewer || {}
if (wasUpvoted) { this.post.likeCount++
this.post.upvoteCount-- this.post.viewer.like = res.uri
} else { })
this.post.upvoteCount++ }
}
this.post.viewer.upvote = res.data.upvote
this.post.viewer.downvote = res.data.downvote
})
}
async toggleDownvote() {
const wasUpvoted = !!this.post.viewer.upvote
const wasDownvoted = !!this.post.viewer.downvote
const res = await this.rootStore.api.app.bsky.feed.setVote({
subject: {
uri: this.post.uri,
cid: this.post.cid,
},
direction: wasDownvoted ? 'none' : 'down',
})
runInAction(() => {
if (wasUpvoted) {
this.post.upvoteCount--
}
if (wasDownvoted) {
this.post.downvoteCount--
} else {
this.post.downvoteCount++
}
this.post.viewer.upvote = res.data.upvote
this.post.viewer.downvote = res.data.downvote
})
} }
async toggleRepost() { async toggleRepost() {
if (this.post.viewer.repost) { if (this.post.viewer?.repost) {
await apilib.unrepost(this.rootStore, this.post.viewer.repost) await this.rootStore.agent.deleteRepost(this.post.viewer.repost)
runInAction(() => { runInAction(() => {
this.post.repostCount = this.post.repostCount || 0
this.post.viewer = this.post.viewer || {}
this.post.repostCount-- this.post.repostCount--
this.post.viewer.repost = undefined this.post.viewer.repost = undefined
}) })
} else { } else {
const res = await apilib.repost( const res = await this.rootStore.agent.repost(
this.rootStore,
this.post.uri, this.post.uri,
this.post.cid, this.post.cid,
) )
runInAction(() => { runInAction(() => {
this.post.repostCount = this.post.repostCount || 0
this.post.viewer = this.post.viewer || {}
this.post.repostCount++ this.post.repostCount++
this.post.viewer.repost = res.uri this.post.viewer.repost = res.uri
}) })
@ -161,10 +129,7 @@ export class FeedItemModel {
} }
async delete() { async delete() {
await this.rootStore.api.app.bsky.feed.post.delete({ await this.rootStore.agent.deletePost(this.post.uri)
did: this.post.author.did,
rkey: new AtUri(this.post.uri).rkey,
})
this.rootStore.emitPostDeleted(this.post.uri) this.rootStore.emitPostDeleted(this.post.uri)
} }
} }
@ -250,7 +215,7 @@ export class FeedModel {
tuner = new FeedTuner() tuner = new FeedTuner()
// used to linearize async modifications to state // used to linearize async modifications to state
private lock = new AwaitLock() lock = new AwaitLock()
// data // data
slices: FeedSliceModel[] = [] slices: FeedSliceModel[] = []
@ -291,8 +256,8 @@ export class FeedModel {
const params = this.params as GetAuthorFeed.QueryParams const params = this.params as GetAuthorFeed.QueryParams
const item = slice.rootItem const item = slice.rootItem
const isRepost = const isRepost =
item?.reasonRepost?.by?.handle === params.author || item?.reasonRepost?.by?.handle === params.actor ||
item?.reasonRepost?.by?.did === params.author item?.reasonRepost?.by?.did === params.actor
return ( return (
!item.reply || // not a reply !item.reply || // not a reply
isRepost || // but allow if it's a repost isRepost || // but allow if it's a repost
@ -338,7 +303,7 @@ export class FeedModel {
return this.setup() return this.setup()
} }
private get feedTuners() { get feedTuners() {
if (this.feedType === 'goodstuff') { if (this.feedType === 'goodstuff') {
return [ return [
FeedTuner.dedupReposts, FeedTuner.dedupReposts,
@ -406,7 +371,7 @@ export class FeedModel {
this._xLoading() this._xLoading()
try { try {
const res = await this._getFeed({ const res = await this._getFeed({
before: this.loadMoreCursor, cursor: this.loadMoreCursor,
limit: PAGE_SIZE, limit: PAGE_SIZE,
}) })
await this._appendAll(res) await this._appendAll(res)
@ -439,7 +404,7 @@ export class FeedModel {
try { try {
do { do {
const res: GetTimeline.Response = await this._getFeed({ const res: GetTimeline.Response = await this._getFeed({
before: cursor, cursor,
limit: Math.min(numToFetch, 100), limit: Math.min(numToFetch, 100),
}) })
if (res.data.feed.length === 0) { if (res.data.feed.length === 0) {
@ -478,14 +443,18 @@ export class FeedModel {
new FeedSliceModel(this.rootStore, `item-${_idCounter++}`, slice), new FeedSliceModel(this.rootStore, `item-${_idCounter++}`, slice),
) )
if (autoPrepend) { if (autoPrepend) {
this.slices = nextSlicesModels.concat( runInAction(() => {
this.slices.filter(slice1 => this.slices = nextSlicesModels.concat(
nextSlicesModels.find(slice2 => slice1.uri === slice2.uri), this.slices.filter(slice1 =>
), nextSlicesModels.find(slice2 => slice1.uri === slice2.uri),
) ),
this.setHasNewLatest(false) )
this.setHasNewLatest(false)
})
} else { } else {
this.nextSlices = nextSlicesModels runInAction(() => {
this.nextSlices = nextSlicesModels
})
this.setHasNewLatest(true) this.setHasNewLatest(true)
} }
} else { } else {
@ -519,13 +488,13 @@ export class FeedModel {
// state transitions // state transitions
// = // =
private _xLoading(isRefreshing = false) { _xLoading(isRefreshing = false) {
this.isLoading = true this.isLoading = true
this.isRefreshing = isRefreshing this.isRefreshing = isRefreshing
this.error = '' this.error = ''
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
@ -538,14 +507,12 @@ export class FeedModel {
// helper functions // helper functions
// = // =
private async _replaceAll( async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
res: GetTimeline.Response | GetAuthorFeed.Response,
) {
this.pollCursor = res.data.feed[0]?.post.uri this.pollCursor = res.data.feed[0]?.post.uri
return this._appendAll(res, true) return this._appendAll(res, true)
} }
private async _appendAll( async _appendAll(
res: GetTimeline.Response | GetAuthorFeed.Response, res: GetTimeline.Response | GetAuthorFeed.Response,
replace = false, 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) { for (const item of res.data.feed) {
const existingSlice = this.slices.find(slice => const existingSlice = this.slices.find(slice =>
slice.containsUri(item.post.uri), slice.containsUri(item.post.uri),
@ -596,7 +563,7 @@ export class FeedModel {
const responses = await getMultipleAuthorsPosts( const responses = await getMultipleAuthorsPosts(
this.rootStore, this.rootStore,
sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20), sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20),
params.before, params.cursor,
20, 20,
) )
const combinedCursor = getCombinedCursors(responses) const combinedCursor = getCombinedCursors(responses)
@ -611,9 +578,7 @@ export class FeedModel {
headers: lastHeaders, headers: lastHeaders,
} }
} else if (this.feedType === 'home') { } else if (this.feedType === 'home') {
return this.rootStore.api.app.bsky.feed.getTimeline( return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
params as GetTimeline.QueryParams,
)
} else if (this.feedType === 'goodstuff') { } else if (this.feedType === 'goodstuff') {
const res = await getGoodStuff( const res = await getGoodStuff(
this.rootStore.session.currentSession?.accessJwt || '', this.rootStore.session.currentSession?.accessJwt || '',
@ -624,7 +589,7 @@ export class FeedModel {
) )
return res return res
} else { } else {
return this.rootStore.api.app.bsky.feed.getAuthorFeed( return this.rootStore.agent.getAuthorFeed(
params as GetAuthorFeed.QueryParams, params as GetAuthorFeed.QueryParams,
) )
} }

View File

@ -1,6 +1,6 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {AtUri} from '../../third-party/uri' 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 {RootStoreModel} from './root-store'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
@ -8,24 +8,24 @@ import * as apilib from 'lib/api/index'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
export type VoteItem = GetVotes.Vote export type LikeItem = GetLikes.Like
export class VotesViewModel { export class LikesViewModel {
// state // state
isLoading = false isLoading = false
isRefreshing = false isRefreshing = false
hasLoaded = false hasLoaded = false
error = '' error = ''
resolvedUri = '' resolvedUri = ''
params: GetVotes.QueryParams params: GetLikes.QueryParams
hasMore = true hasMore = true
loadMoreCursor?: string loadMoreCursor?: string
// data // data
uri: string = '' uri: string = ''
votes: VoteItem[] = [] likes: LikeItem[] = []
constructor(public rootStore: RootStoreModel, params: GetVotes.QueryParams) { constructor(public rootStore: RootStoreModel, params: GetLikes.QueryParams) {
makeAutoObservable( makeAutoObservable(
this, this,
{ {
@ -68,9 +68,9 @@ export class VotesViewModel {
const params = Object.assign({}, this.params, { const params = Object.assign({}, this.params, {
uri: this.resolvedUri, uri: this.resolvedUri,
limit: PAGE_SIZE, 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) { if (replace) {
this._replaceAll(res) this._replaceAll(res)
} else { } else {
@ -85,13 +85,13 @@ export class VotesViewModel {
// state transitions // state transitions
// = // =
private _xLoading(isRefreshing = false) { _xLoading(isRefreshing = false) {
this.isLoading = true this.isLoading = true
this.isRefreshing = isRefreshing this.isRefreshing = isRefreshing
this.error = '' this.error = ''
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
@ -104,7 +104,7 @@ export class VotesViewModel {
// helper functions // helper functions
// = // =
private async _resolveUri() { async _resolveUri() {
const urip = new AtUri(this.params.uri) const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) { if (!urip.host.startsWith('did:')) {
try { try {
@ -118,14 +118,14 @@ export class VotesViewModel {
}) })
} }
private _replaceAll(res: GetVotes.Response) { _replaceAll(res: GetLikes.Response) {
this.votes = [] this.likes = []
this._appendAll(res) this._appendAll(res)
} }
private _appendAll(res: GetVotes.Response) { _appendAll(res: GetLikes.Response) {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor 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 {makeAutoObservable} from 'mobx'
import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' // import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' TODO
const MAX_ENTRIES = 300 const MAX_ENTRIES = 300
@ -32,7 +32,7 @@ export class LogModel {
makeAutoObservable(this) makeAutoObservable(this)
} }
private add(entry: LogEntry) { add(entry: LogEntry) {
this.entries.push(entry) this.entries.push(entry)
while (this.entries.length > MAX_ENTRIES) { while (this.entries.length > MAX_ENTRIES) {
this.entries = this.entries.slice(50) this.entries = this.entries.slice(50)
@ -79,14 +79,14 @@ export class LogModel {
function detailsToStr(details?: any) { function detailsToStr(details?: any) {
if (details && typeof details !== 'string') { if (details && typeof details !== 'string') {
if ( if (
details instanceof XRPCInvalidResponseError || // details instanceof XRPCInvalidResponseError || TODO
details.constructor.name === 'XRPCInvalidResponseError' details.constructor.name === 'XRPCInvalidResponseError'
) { ) {
return `The server gave an ill-formatted response.\nMethod: ${ return `The server gave an ill-formatted response.\nMethod: ${
details.lexiconNsid details.lexiconNsid
}.\nError: ${details.validationError.toString()}` }.\nError: ${details.validationError.toString()}`
} else if ( } else if (
details instanceof XRPCError || // details instanceof XRPCError || TODO
details.constructor.name === 'XRPCError' details.constructor.name === 'XRPCError'
) { ) {
return `An XRPC error occurred.\nStatus: ${details.status}\nError: ${details.error}\nMessage: ${details.message}` 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) { if (sess.hasSession) {
this.did = sess.currentSession?.did || '' this.did = sess.currentSession?.did || ''
this.handle = sess.currentSession?.handle || '' 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, actor: this.did,
}) })
runInAction(() => { runInAction(() => {

View File

@ -1,11 +1,10 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import { import {
AppBskyNotificationList as ListNotifications, AppBskyNotificationListNotifications as ListNotifications,
AppBskyActorRef as ActorRef, AppBskyActorDefs,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyFeedRepost, AppBskyFeedRepost,
AppBskyFeedVote, AppBskyFeedLike,
AppBskyGraphAssertion,
AppBskyGraphFollow, AppBskyGraphFollow,
} from '@atproto/api' } from '@atproto/api'
import AwaitLock from 'await-lock' import AwaitLock from 'await-lock'
@ -28,8 +27,7 @@ export interface GroupedNotification extends ListNotifications.Notification {
type SupportedRecord = type SupportedRecord =
| AppBskyFeedPost.Record | AppBskyFeedPost.Record
| AppBskyFeedRepost.Record | AppBskyFeedRepost.Record
| AppBskyFeedVote.Record | AppBskyFeedLike.Record
| AppBskyGraphAssertion.Record
| AppBskyGraphFollow.Record | AppBskyGraphFollow.Record
export class NotificationsViewItemModel { export class NotificationsViewItemModel {
@ -39,11 +37,10 @@ export class NotificationsViewItemModel {
// data // data
uri: string = '' uri: string = ''
cid: string = '' cid: string = ''
author: ActorRef.WithInfo = { author: AppBskyActorDefs.ProfileViewBasic = {
did: '', did: '',
handle: '', handle: '',
avatar: '', avatar: '',
declaration: {cid: '', actorType: ''},
} }
reason: string = '' reason: string = ''
reasonSubject?: string reasonSubject?: string
@ -86,8 +83,8 @@ export class NotificationsViewItemModel {
} }
} }
get isUpvote() { get isLike() {
return this.reason === 'vote' return this.reason === 'like'
} }
get isRepost() { get isRepost() {
@ -102,16 +99,22 @@ export class NotificationsViewItemModel {
return this.reason === 'reply' return this.reason === 'reply'
} }
get isQuote() {
return this.reason === 'quote'
}
get isFollow() { get isFollow() {
return this.reason === 'follow' return this.reason === 'follow'
} }
get isAssertion() {
return this.reason === 'assertion'
}
get needsAdditionalData() { 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 !this.additionalPost
} }
return false return false
@ -124,7 +127,7 @@ export class NotificationsViewItemModel {
const record = this.record const record = this.record
if ( if (
AppBskyFeedRepost.isRecord(record) || AppBskyFeedRepost.isRecord(record) ||
AppBskyFeedVote.isRecord(record) AppBskyFeedLike.isRecord(record)
) { ) {
return record.subject.uri return record.subject.uri
} }
@ -135,8 +138,7 @@ export class NotificationsViewItemModel {
for (const ns of [ for (const ns of [
AppBskyFeedPost, AppBskyFeedPost,
AppBskyFeedRepost, AppBskyFeedRepost,
AppBskyFeedVote, AppBskyFeedLike,
AppBskyGraphAssertion,
AppBskyGraphFollow, AppBskyGraphFollow,
]) { ]) {
if (ns.isRecord(v)) { if (ns.isRecord(v)) {
@ -163,9 +165,9 @@ export class NotificationsViewItemModel {
return return
} }
let postUri let postUri
if (this.isReply || this.isMention) { if (this.isReply || this.isQuote || this.isMention) {
postUri = this.uri postUri = this.uri
} else if (this.isUpvote || this.isRepost) { } else if (this.isLike || this.isRepost) {
postUri = this.subjectUri postUri = this.subjectUri
} }
if (postUri) { if (postUri) {
@ -194,7 +196,7 @@ export class NotificationsViewModel {
loadMoreCursor?: string loadMoreCursor?: string
// used to linearize async modifications to state // used to linearize async modifications to state
private lock = new AwaitLock() lock = new AwaitLock()
// data // data
notifications: NotificationsViewItemModel[] = [] notifications: NotificationsViewItemModel[] = []
@ -266,7 +268,7 @@ export class NotificationsViewModel {
const params = Object.assign({}, this.params, { const params = Object.assign({}, this.params, {
limit: PAGE_SIZE, 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) await this._replaceAll(res)
this._xIdle() this._xIdle()
} catch (e: any) { } catch (e: any) {
@ -297,9 +299,9 @@ export class NotificationsViewModel {
try { try {
const params = Object.assign({}, this.params, { const params = Object.assign({}, this.params, {
limit: PAGE_SIZE, 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) await this._appendAll(res)
this._xIdle() this._xIdle()
} catch (e: any) { } catch (e: any) {
@ -325,7 +327,7 @@ export class NotificationsViewModel {
try { try {
this._xLoading() this._xLoading()
try { try {
const res = await this.rootStore.api.app.bsky.notification.list({ const res = await this.rootStore.agent.listNotifications({
limit: PAGE_SIZE, limit: PAGE_SIZE,
}) })
await this._prependAll(res) await this._prependAll(res)
@ -357,8 +359,8 @@ export class NotificationsViewModel {
try { try {
do { do {
const res: ListNotifications.Response = const res: ListNotifications.Response =
await this.rootStore.api.app.bsky.notification.list({ await this.rootStore.agent.listNotifications({
before: cursor, cursor,
limit: Math.min(numToFetch, 100), limit: Math.min(numToFetch, 100),
}) })
if (res.data.notifications.length === 0) { if (res.data.notifications.length === 0) {
@ -390,7 +392,7 @@ export class NotificationsViewModel {
*/ */
loadUnreadCount = bundleAsync(async () => { loadUnreadCount = bundleAsync(async () => {
const old = this.unreadCount const old = this.unreadCount
const res = await this.rootStore.api.app.bsky.notification.getCount() const res = await this.rootStore.agent.countUnreadNotifications()
runInAction(() => { runInAction(() => {
this.unreadCount = res.data.count this.unreadCount = res.data.count
}) })
@ -408,9 +410,7 @@ export class NotificationsViewModel {
for (const notif of this.notifications) { for (const notif of this.notifications) {
notif.isRead = true notif.isRead = true
} }
await this.rootStore.api.app.bsky.notification.updateSeen({ await this.rootStore.agent.updateSeenNotifications()
seenAt: new Date().toISOString(),
})
} catch (e: any) { } catch (e: any) {
this.rootStore.log.warn('Failed to update notifications read state', e) this.rootStore.log.warn('Failed to update notifications read state', e)
} }
@ -418,7 +418,7 @@ export class NotificationsViewModel {
async getNewMostRecent(): Promise<NotificationsViewItemModel | undefined> { async getNewMostRecent(): Promise<NotificationsViewItemModel | undefined> {
let old = this.mostRecentNotificationUri let old = this.mostRecentNotificationUri
const res = await this.rootStore.api.app.bsky.notification.list({ const res = await this.rootStore.agent.listNotifications({
limit: 1, limit: 1,
}) })
if (!res.data.notifications[0] || old === res.data.notifications[0].uri) { if (!res.data.notifications[0] || old === res.data.notifications[0].uri) {
@ -437,13 +437,13 @@ export class NotificationsViewModel {
// state transitions // state transitions
// = // =
private _xLoading(isRefreshing = false) { _xLoading(isRefreshing = false) {
this.isLoading = true this.isLoading = true
this.isRefreshing = isRefreshing this.isRefreshing = isRefreshing
this.error = '' this.error = ''
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
@ -456,14 +456,14 @@ export class NotificationsViewModel {
// helper functions // helper functions
// = // =
private async _replaceAll(res: ListNotifications.Response) { async _replaceAll(res: ListNotifications.Response) {
if (res.data.notifications[0]) { if (res.data.notifications[0]) {
this.mostRecentNotificationUri = res.data.notifications[0].uri this.mostRecentNotificationUri = res.data.notifications[0].uri
} }
return this._appendAll(res, true) 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.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
const promises = [] const promises = []
@ -494,7 +494,7 @@ export class NotificationsViewModel {
}) })
} }
private async _prependAll(res: ListNotifications.Response) { async _prependAll(res: ListNotifications.Response) {
const promises = [] const promises = []
const itemModels: NotificationsViewItemModel[] = [] const itemModels: NotificationsViewItemModel[] = []
const dedupedNotifs = res.data.notifications.filter( 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) { for (const item of res.data.notifications) {
const existingItem = this.notifications.find(item2 => isEq(item, item2)) const existingItem = this.notifications.find(item2 => isEq(item, item2))
if (existingItem) { if (existingItem) {

View File

@ -2,12 +2,13 @@ import {makeAutoObservable, runInAction} from 'mobx'
import { import {
AppBskyFeedGetPostThread as GetPostThread, AppBskyFeedGetPostThread as GetPostThread,
AppBskyFeedPost as FeedPost, AppBskyFeedPost as FeedPost,
AppBskyFeedDefs,
RichText,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '../../third-party/uri' import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {RichText} from 'lib/strings/rich-text'
function* reactKeyGenerator(): Generator<string> { function* reactKeyGenerator(): Generator<string> {
let counter = 0 let counter = 0
@ -26,10 +27,10 @@ export class PostThreadViewPostModel {
_hasMore = false _hasMore = false
// data // data
post: FeedPost.View post: AppBskyFeedDefs.PostView
postRecord?: FeedPost.Record postRecord?: FeedPost.Record
parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost parent?: PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost
replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[] replies?: (PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost)[]
richText?: RichText richText?: RichText
get uri() { get uri() {
@ -43,7 +44,7 @@ export class PostThreadViewPostModel {
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
reactKey: string, reactKey: string,
v: GetPostThread.ThreadViewPost, v: AppBskyFeedDefs.ThreadViewPost,
) { ) {
this._reactKey = reactKey this._reactKey = reactKey
this.post = v.post this.post = v.post
@ -51,11 +52,7 @@ export class PostThreadViewPostModel {
const valid = FeedPost.validateRecord(this.post.record) const valid = FeedPost.validateRecord(this.post.record)
if (valid.success) { if (valid.success) {
this.postRecord = this.post.record this.postRecord = this.post.record
this.richText = new RichText( this.richText = new RichText(this.postRecord, {cleanNewlines: true})
this.postRecord.text,
this.postRecord.entities,
{cleanNewlines: true},
)
} else { } else {
rootStore.log.warn( rootStore.log.warn(
'Received an invalid app.bsky.feed.post record', 'Received an invalid app.bsky.feed.post record',
@ -74,14 +71,14 @@ export class PostThreadViewPostModel {
assignTreeModels( assignTreeModels(
keyGen: Generator<string>, keyGen: Generator<string>,
v: GetPostThread.ThreadViewPost, v: AppBskyFeedDefs.ThreadViewPost,
higlightedPostUri: string, higlightedPostUri: string,
includeParent = true, includeParent = true,
includeChildren = true, includeChildren = true,
) { ) {
// parents // parents
if (includeParent && v.parent) { if (includeParent && v.parent) {
if (GetPostThread.isThreadViewPost(v.parent)) { if (AppBskyFeedDefs.isThreadViewPost(v.parent)) {
const parentModel = new PostThreadViewPostModel( const parentModel = new PostThreadViewPostModel(
this.rootStore, this.rootStore,
keyGen.next().value, keyGen.next().value,
@ -100,7 +97,7 @@ export class PostThreadViewPostModel {
) )
} }
this.parent = parentModel this.parent = parentModel
} else if (GetPostThread.isNotFoundPost(v.parent)) { } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
this.parent = v.parent this.parent = v.parent
} }
} }
@ -108,7 +105,7 @@ export class PostThreadViewPostModel {
if (includeChildren && v.replies) { if (includeChildren && v.replies) {
const replies = [] const replies = []
for (const item of v.replies) { for (const item of v.replies) {
if (GetPostThread.isThreadViewPost(item)) { if (AppBskyFeedDefs.isThreadViewPost(item)) {
const itemModel = new PostThreadViewPostModel( const itemModel = new PostThreadViewPostModel(
this.rootStore, this.rootStore,
keyGen.next().value, keyGen.next().value,
@ -128,7 +125,7 @@ export class PostThreadViewPostModel {
) )
} }
replies.push(itemModel) replies.push(itemModel)
} else if (GetPostThread.isNotFoundPost(item)) { } else if (AppBskyFeedDefs.isNotFoundPost(item)) {
replies.push(item) replies.push(item)
} }
} }
@ -136,68 +133,43 @@ export class PostThreadViewPostModel {
} }
} }
async toggleUpvote() { async toggleLike() {
const wasUpvoted = !!this.post.viewer.upvote if (this.post.viewer?.like) {
const wasDownvoted = !!this.post.viewer.downvote await this.rootStore.agent.deleteLike(this.post.viewer.like)
const res = await this.rootStore.api.app.bsky.feed.setVote({ runInAction(() => {
subject: { this.post.likeCount = this.post.likeCount || 0
uri: this.post.uri, this.post.viewer = this.post.viewer || {}
cid: this.post.cid, this.post.likeCount--
}, this.post.viewer.like = undefined
direction: wasUpvoted ? 'none' : 'up', })
}) } else {
runInAction(() => { const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
if (wasDownvoted) { runInAction(() => {
this.post.downvoteCount-- this.post.likeCount = this.post.likeCount || 0
} this.post.viewer = this.post.viewer || {}
if (wasUpvoted) { this.post.likeCount++
this.post.upvoteCount-- this.post.viewer.like = res.uri
} else { })
this.post.upvoteCount++ }
}
this.post.viewer.upvote = res.data.upvote
this.post.viewer.downvote = res.data.downvote
})
}
async toggleDownvote() {
const wasUpvoted = !!this.post.viewer.upvote
const wasDownvoted = !!this.post.viewer.downvote
const res = await this.rootStore.api.app.bsky.feed.setVote({
subject: {
uri: this.post.uri,
cid: this.post.cid,
},
direction: wasDownvoted ? 'none' : 'down',
})
runInAction(() => {
if (wasUpvoted) {
this.post.upvoteCount--
}
if (wasDownvoted) {
this.post.downvoteCount--
} else {
this.post.downvoteCount++
}
this.post.viewer.upvote = res.data.upvote
this.post.viewer.downvote = res.data.downvote
})
} }
async toggleRepost() { async toggleRepost() {
if (this.post.viewer.repost) { if (this.post.viewer?.repost) {
await apilib.unrepost(this.rootStore, this.post.viewer.repost) await this.rootStore.agent.deleteRepost(this.post.viewer.repost)
runInAction(() => { runInAction(() => {
this.post.repostCount = this.post.repostCount || 0
this.post.viewer = this.post.viewer || {}
this.post.repostCount-- this.post.repostCount--
this.post.viewer.repost = undefined this.post.viewer.repost = undefined
}) })
} else { } else {
const res = await apilib.repost( const res = await this.rootStore.agent.repost(
this.rootStore,
this.post.uri, this.post.uri,
this.post.cid, this.post.cid,
) )
runInAction(() => { runInAction(() => {
this.post.repostCount = this.post.repostCount || 0
this.post.viewer = this.post.viewer || {}
this.post.repostCount++ this.post.repostCount++
this.post.viewer.repost = res.uri this.post.viewer.repost = res.uri
}) })
@ -205,10 +177,7 @@ export class PostThreadViewPostModel {
} }
async delete() { async delete() {
await this.rootStore.api.app.bsky.feed.post.delete({ await this.rootStore.agent.deletePost(this.post.uri)
did: this.post.author.did,
rkey: new AtUri(this.post.uri).rkey,
})
this.rootStore.emitPostDeleted(this.post.uri) this.rootStore.emitPostDeleted(this.post.uri)
} }
} }
@ -301,14 +270,14 @@ export class PostThreadViewModel {
// state transitions // state transitions
// = // =
private _xLoading(isRefreshing = false) { _xLoading(isRefreshing = false) {
this.isLoading = true this.isLoading = true
this.isRefreshing = isRefreshing this.isRefreshing = isRefreshing
this.error = '' this.error = ''
this.notFound = false this.notFound = false
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
@ -322,7 +291,7 @@ export class PostThreadViewModel {
// loader functions // loader functions
// = // =
private async _resolveUri() { async _resolveUri() {
const urip = new AtUri(this.params.uri) const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) { if (!urip.host.startsWith('did:')) {
try { try {
@ -336,10 +305,10 @@ export class PostThreadViewModel {
}) })
} }
private async _load(isRefreshing = false) { async _load(isRefreshing = false) {
this._xLoading(isRefreshing) this._xLoading(isRefreshing)
try { 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}), Object.assign({}, this.params, {uri: this.resolvedUri}),
) )
this._replaceAll(res) this._replaceAll(res)
@ -349,18 +318,18 @@ export class PostThreadViewModel {
} }
} }
private _replaceAll(res: GetPostThread.Response) { _replaceAll(res: GetPostThread.Response) {
sortThread(res.data.thread) sortThread(res.data.thread)
const keyGen = reactKeyGenerator() const keyGen = reactKeyGenerator()
const thread = new PostThreadViewPostModel( const thread = new PostThreadViewPostModel(
this.rootStore, this.rootStore,
keyGen.next().value, keyGen.next().value,
res.data.thread as GetPostThread.ThreadViewPost, res.data.thread as AppBskyFeedDefs.ThreadViewPost,
) )
thread._isHighlightedPost = true thread._isHighlightedPost = true
thread.assignTreeModels( thread.assignTreeModels(
keyGen, keyGen,
res.data.thread as GetPostThread.ThreadViewPost, res.data.thread as AppBskyFeedDefs.ThreadViewPost,
thread.uri, thread.uri,
) )
this.thread = thread this.thread = thread
@ -368,25 +337,25 @@ export class PostThreadViewModel {
} }
type MaybePost = type MaybePost =
| GetPostThread.ThreadViewPost | AppBskyFeedDefs.ThreadViewPost
| GetPostThread.NotFoundPost | AppBskyFeedDefs.NotFoundPost
| {[k: string]: unknown; $type: string} | {[k: string]: unknown; $type: string}
function sortThread(post: MaybePost) { function sortThread(post: MaybePost) {
if (post.notFound) { if (post.notFound) {
return return
} }
post = post as GetPostThread.ThreadViewPost post = post as AppBskyFeedDefs.ThreadViewPost
if (post.replies) { if (post.replies) {
post.replies.sort((a: MaybePost, b: MaybePost) => { post.replies.sort((a: MaybePost, b: MaybePost) => {
post = post as GetPostThread.ThreadViewPost post = post as AppBskyFeedDefs.ThreadViewPost
if (a.notFound) { if (a.notFound) {
return 1 return 1
} }
if (b.notFound) { if (b.notFound) {
return -1 return -1
} }
a = a as GetPostThread.ThreadViewPost a = a as AppBskyFeedDefs.ThreadViewPost
b = b as GetPostThread.ThreadViewPost b = b as AppBskyFeedDefs.ThreadViewPost
const aIsByOp = a.post.author.did === post.post.author.did const aIsByOp = a.post.author.did === post.post.author.did
const bIsByOp = b.post.author.did === post.post.author.did const bIsByOp = b.post.author.did === post.post.author.did
if (aIsByOp && bIsByOp) { if (aIsByOp && bIsByOp) {

View File

@ -58,12 +58,12 @@ export class PostModel implements RemoveIndex<Post.Record> {
// state transitions // state transitions
// = // =
private _xLoading() { _xLoading() {
this.isLoading = true this.isLoading = true
this.error = '' this.error = ''
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.hasLoaded = true this.hasLoaded = true
this.error = cleanError(err) this.error = cleanError(err)
@ -75,12 +75,12 @@ export class PostModel implements RemoveIndex<Post.Record> {
// loader functions // loader functions
// = // =
private async _load() { async _load() {
this._xLoading() this._xLoading()
try { try {
const urip = new AtUri(this.uri) const urip = new AtUri(this.uri)
const res = await this.rootStore.api.app.bsky.feed.post.get({ const res = await this.rootStore.agent.getPost({
user: urip.host, repo: urip.host,
rkey: urip.rkey, rkey: urip.rkey,
}) })
// TODO // 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.text = res.text
this.entities = res.entities this.entities = res.entities
this.reply = res.reply this.reply = res.reply

View File

@ -2,15 +2,12 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {PickedMedia} from 'lib/media/picker' import {PickedMedia} from 'lib/media/picker'
import { import {
AppBskyActorGetProfile as GetProfile, AppBskyActorGetProfile as GetProfile,
AppBskySystemDeclRef, AppBskyActorProfile,
AppBskyActorUpdateProfile, RichText,
} from '@atproto/api' } from '@atproto/api'
type DeclRef = AppBskySystemDeclRef.Main
import {extractEntities} from 'lib/strings/rich-text-detection'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {RichText} from 'lib/strings/rich-text'
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
@ -35,22 +32,18 @@ export class ProfileViewModel {
// data // data
did: string = '' did: string = ''
handle: string = '' handle: string = ''
declaration: DeclRef = {
cid: '',
actorType: '',
}
creator: string = '' creator: string = ''
displayName?: string displayName?: string = ''
description?: string description?: string = ''
avatar?: string avatar?: string = ''
banner?: string banner?: string = ''
followersCount: number = 0 followersCount: number = 0
followsCount: number = 0 followsCount: number = 0
postsCount: number = 0 postsCount: number = 0
viewer = new ProfileViewViewerModel() viewer = new ProfileViewViewerModel()
// added data // added data
descriptionRichText?: RichText descriptionRichText?: RichText = new RichText({text: ''})
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
@ -79,10 +72,6 @@ export class ProfileViewModel {
return this.hasLoaded && !this.hasContent return this.hasLoaded && !this.hasContent
} }
get isUser() {
return this.declaration.actorType === ACTOR_TYPE_USER
}
// public api // public api
// = // =
@ -111,18 +100,14 @@ export class ProfileViewModel {
} }
if (followUri) { if (followUri) {
await apilib.unfollow(this.rootStore, followUri) await this.rootStore.agent.deleteFollow(followUri)
runInAction(() => { runInAction(() => {
this.followersCount-- this.followersCount--
this.viewer.following = undefined this.viewer.following = undefined
this.rootStore.me.follows.removeFollow(this.did) this.rootStore.me.follows.removeFollow(this.did)
}) })
} else { } else {
const res = await apilib.follow( const res = await this.rootStore.agent.follow(this.did)
this.rootStore,
this.did,
this.declaration.cid,
)
runInAction(() => { runInAction(() => {
this.followersCount++ this.followersCount++
this.viewer.following = res.uri this.viewer.following = res.uri
@ -132,49 +117,48 @@ export class ProfileViewModel {
} }
async updateProfile( async updateProfile(
updates: AppBskyActorUpdateProfile.InputSchema, updates: AppBskyActorProfile.Record,
newUserAvatar: PickedMedia | undefined | null, newUserAvatar: PickedMedia | undefined | null,
newUserBanner: PickedMedia | undefined | null, newUserBanner: PickedMedia | undefined | null,
) { ) {
if (newUserAvatar) { await this.rootStore.agent.upsertProfile(async existing => {
const res = await apilib.uploadBlob( existing = existing || {}
this.rootStore, existing.displayName = updates.displayName
newUserAvatar.path, existing.description = updates.description
newUserAvatar.mime, if (newUserAvatar) {
) const res = await apilib.uploadBlob(
updates.avatar = { this.rootStore,
cid: res.data.cid, newUserAvatar.path,
mimeType: newUserAvatar.mime, newUserAvatar.mime,
)
existing.avatar = res.data.blob
} else if (newUserAvatar === null) {
existing.avatar = undefined
} }
} else if (newUserAvatar === null) { if (newUserBanner) {
updates.avatar = null const res = await apilib.uploadBlob(
} this.rootStore,
if (newUserBanner) { newUserBanner.path,
const res = await apilib.uploadBlob( newUserBanner.mime,
this.rootStore, )
newUserBanner.path, existing.banner = res.data.blob
newUserBanner.mime, } else if (newUserBanner === null) {
) existing.banner = undefined
updates.banner = {
cid: res.data.cid,
mimeType: newUserBanner.mime,
} }
} else if (newUserBanner === null) { return existing
updates.banner = null })
}
await this.rootStore.api.app.bsky.actor.updateProfile(updates)
await this.rootStore.me.load() await this.rootStore.me.load()
await this.refresh() await this.refresh()
} }
async muteAccount() { async muteAccount() {
await this.rootStore.api.app.bsky.graph.mute({user: this.did}) await this.rootStore.agent.mute(this.did)
this.viewer.muted = true this.viewer.muted = true
await this.refresh() await this.refresh()
} }
async unmuteAccount() { async unmuteAccount() {
await this.rootStore.api.app.bsky.graph.unmute({user: this.did}) await this.rootStore.agent.unmute(this.did)
this.viewer.muted = false this.viewer.muted = false
await this.refresh() await this.refresh()
} }
@ -182,13 +166,13 @@ export class ProfileViewModel {
// state transitions // state transitions
// = // =
private _xLoading(isRefreshing = false) { _xLoading(isRefreshing = false) {
this.isLoading = true this.isLoading = true
this.isRefreshing = isRefreshing this.isRefreshing = isRefreshing
this.error = '' this.error = ''
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
@ -201,40 +185,40 @@ export class ProfileViewModel {
// loader functions // loader functions
// = // =
private async _load(isRefreshing = false) { async _load(isRefreshing = false) {
this._xLoading(isRefreshing) this._xLoading(isRefreshing)
try { try {
const res = await this.rootStore.api.app.bsky.actor.getProfile( const res = await this.rootStore.agent.getProfile(this.params)
this.params,
)
this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
this._replaceAll(res) this._replaceAll(res)
await this._createRichText()
this._xIdle() this._xIdle()
} catch (e: any) { } catch (e: any) {
this._xIdle(e) this._xIdle(e)
} }
} }
private _replaceAll(res: GetProfile.Response) { _replaceAll(res: GetProfile.Response) {
this.did = res.data.did this.did = res.data.did
this.handle = res.data.handle this.handle = res.data.handle
Object.assign(this.declaration, res.data.declaration)
this.creator = res.data.creator
this.displayName = res.data.displayName this.displayName = res.data.displayName
this.description = res.data.description this.description = res.data.description
this.avatar = res.data.avatar this.avatar = res.data.avatar
this.banner = res.data.banner this.banner = res.data.banner
this.followersCount = res.data.followersCount this.followersCount = res.data.followersCount || 0
this.followsCount = res.data.followsCount this.followsCount = res.data.followsCount || 0
this.postsCount = res.data.postsCount this.postsCount = res.data.postsCount || 0
if (res.data.viewer) { if (res.data.viewer) {
Object.assign(this.viewer, res.data.viewer) Object.assign(this.viewer, res.data.viewer)
this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following) this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
} }
}
async _createRichText() {
this.descriptionRichText = new RichText( this.descriptionRichText = new RichText(
this.description || '', {text: this.description || ''},
extractEntities(this.description || ''),
{cleanNewlines: true}, {cleanNewlines: true},
) )
await this.descriptionRichText.detectFacets(this.rootStore.agent)
} }
} }

View File

@ -31,7 +31,7 @@ export class ProfilesViewModel {
} }
} }
try { try {
const promise = this.rootStore.api.app.bsky.actor.getProfile({ const promise = this.rootStore.agent.getProfile({
actor: did, actor: did,
}) })
this.cache.set(did, promise) this.cache.set(did, promise)

View File

@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {AtUri} from '../../third-party/uri' import {AtUri} from '../../third-party/uri'
import { import {
AppBskyFeedGetRepostedBy as GetRepostedBy, AppBskyFeedGetRepostedBy as GetRepostedBy,
AppBskyActorRef as ActorRef, AppBskyActorDefs,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
@ -11,7 +11,7 @@ import * as apilib from 'lib/api/index'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
export type RepostedByItem = ActorRef.WithInfo export type RepostedByItem = AppBskyActorDefs.ProfileViewBasic
export class RepostedByViewModel { export class RepostedByViewModel {
// state // state
@ -71,9 +71,9 @@ export class RepostedByViewModel {
const params = Object.assign({}, this.params, { const params = Object.assign({}, this.params, {
uri: this.resolvedUri, uri: this.resolvedUri,
limit: PAGE_SIZE, 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) { if (replace) {
this._replaceAll(res) this._replaceAll(res)
} else { } else {
@ -88,13 +88,13 @@ export class RepostedByViewModel {
// state transitions // state transitions
// = // =
private _xLoading(isRefreshing = false) { _xLoading(isRefreshing = false) {
this.isLoading = true this.isLoading = true
this.isRefreshing = isRefreshing this.isRefreshing = isRefreshing
this.error = '' this.error = ''
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
@ -107,7 +107,7 @@ export class RepostedByViewModel {
// helper functions // helper functions
// = // =
private async _resolveUri() { async _resolveUri() {
const urip = new AtUri(this.params.uri) const urip = new AtUri(this.params.uri)
if (!urip.host.startsWith('did:')) { if (!urip.host.startsWith('did:')) {
try { try {
@ -121,12 +121,12 @@ export class RepostedByViewModel {
}) })
} }
private _replaceAll(res: GetRepostedBy.Response) { _replaceAll(res: GetRepostedBy.Response) {
this.repostedBy = [] this.repostedBy = []
this._appendAll(res) this._appendAll(res)
} }
private _appendAll(res: GetRepostedBy.Response) { _appendAll(res: GetRepostedBy.Response) {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.repostedBy = this.repostedBy.concat(res.data.repostedBy) this.repostedBy = this.repostedBy.concat(res.data.repostedBy)

View File

@ -2,8 +2,8 @@
* The root store is the base of all modeled state. * The root store is the base of all modeled state.
*/ */
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable} from 'mobx'
import {AtpAgent} from '@atproto/api' import {BskyAgent} from '@atproto/api'
import {createContext, useContext} from 'react' import {createContext, useContext} from 'react'
import {DeviceEventEmitter, EmitterSubscription} from 'react-native' import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
import * as BgScheduler from 'lib/bg-scheduler' import * as BgScheduler from 'lib/bg-scheduler'
@ -29,7 +29,7 @@ export const appInfo = z.object({
export type AppInfo = z.infer<typeof appInfo> export type AppInfo = z.infer<typeof appInfo>
export class RootStoreModel { export class RootStoreModel {
agent: AtpAgent agent: BskyAgent
appInfo?: AppInfo appInfo?: AppInfo
log = new LogModel() log = new LogModel()
session = new SessionModel(this) session = new SessionModel(this)
@ -40,41 +40,16 @@ export class RootStoreModel {
linkMetas = new LinkMetasCache(this) linkMetas = new LinkMetasCache(this)
imageSizes = new ImageSizesCache() imageSizes = new ImageSizesCache()
// HACK constructor(agent: BskyAgent) {
// 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) {
this.agent = agent this.agent = agent
makeAutoObservable(this, { makeAutoObservable(this, {
api: false, agent: false,
serialize: false, serialize: false,
hydrate: false, hydrate: false,
}) })
this.initBgFetch() this.initBgFetch()
} }
get api() {
return this.agent.api
}
setAppInfo(info: AppInfo) { setAppInfo(info: AppInfo) {
this.appInfo = info this.appInfo = info
} }
@ -131,7 +106,7 @@ export class RootStoreModel {
/** /**
* Called by the session model. Refreshes session-oriented state. * Called by the session model. Refreshes session-oriented state.
*/ */
async handleSessionChange(agent: AtpAgent) { async handleSessionChange(agent: BskyAgent) {
this.log.debug('RootStoreModel:handleSessionChange') this.log.debug('RootStoreModel:handleSessionChange')
this.agent = agent this.agent = agent
this.me.clear() this.me.clear()
@ -259,7 +234,7 @@ export class RootStoreModel {
async onBgFetch(taskId: string) { async onBgFetch(taskId: string) {
this.log.debug(`Background fetch fired for task ${taskId}`) this.log.debug(`Background fetch fired for task ${taskId}`)
if (this.session.hasSession) { 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 const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count
this.emitUnreadNotifications(res.data.count) this.emitUnreadNotifications(res.data.count)
this.log.debug( this.log.debug(
@ -286,7 +261,7 @@ export class RootStoreModel {
} }
const throwawayInst = new 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 ) // this will be replaced by the loader, we just need to supply a value at init
const RootStoreContext = createContext<RootStoreModel>(throwawayInst) const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
export const RootStoreProvider = RootStoreContext.Provider export const RootStoreProvider = RootStoreContext.Provider

View File

@ -1,9 +1,9 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import { import {
AtpAgent, BskyAgent,
AtpSessionEvent, AtpSessionEvent,
AtpSessionData, AtpSessionData,
ComAtprotoServerGetAccountsConfig as GetAccountsConfig, ComAtprotoServerDescribeServer as DescribeServer,
} from '@atproto/api' } from '@atproto/api'
import normalizeUrl from 'normalize-url' import normalizeUrl from 'normalize-url'
import {isObj, hasProp} from 'lib/type-guards' import {isObj, hasProp} from 'lib/type-guards'
@ -11,7 +11,7 @@ import {networkRetry} from 'lib/async/retry'
import {z} from 'zod' import {z} from 'zod'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
export type ServiceDescription = GetAccountsConfig.OutputSchema export type ServiceDescription = DescribeServer.OutputSchema
export const activeSession = z.object({ export const activeSession = z.object({
service: z.string(), service: z.string(),
@ -40,7 +40,7 @@ export class SessionModel {
// emergency log facility to help us track down this logout issue // emergency log facility to help us track down this logout issue
// remove when resolved // remove when resolved
// -prf // -prf
private _log(message: string, details?: Record<string, any>) { _log(message: string, details?: Record<string, any>) {
details = details || {} details = details || {}
details.state = { details.state = {
data: this.data, data: this.data,
@ -73,6 +73,7 @@ export class SessionModel {
rootStore: false, rootStore: false,
serialize: false, serialize: false,
hydrate: false, hydrate: false,
hasSession: false,
}) })
} }
@ -154,7 +155,7 @@ export class SessionModel {
/** /**
* Sets the active session * Sets the active session
*/ */
async setActiveSession(agent: AtpAgent, did: string) { async setActiveSession(agent: BskyAgent, did: string) {
this._log('SessionModel:setActiveSession') this._log('SessionModel:setActiveSession')
this.data = { this.data = {
service: agent.service.toString(), service: agent.service.toString(),
@ -166,7 +167,7 @@ export class SessionModel {
/** /**
* Upserts a session into the accounts * Upserts a session into the accounts
*/ */
private persistSession( persistSession(
service: string, service: string,
did: string, did: string,
event: AtpSessionEvent, event: AtpSessionEvent,
@ -225,7 +226,7 @@ export class SessionModel {
/** /**
* Clears any session tokens from the accounts; used on logout. * Clears any session tokens from the accounts; used on logout.
*/ */
private clearSessionTokens() { clearSessionTokens() {
this._log('SessionModel:clearSessionTokens') this._log('SessionModel:clearSessionTokens')
this.accounts = this.accounts.map(acct => ({ this.accounts = this.accounts.map(acct => ({
service: acct.service, service: acct.service,
@ -239,10 +240,8 @@ export class SessionModel {
/** /**
* Fetches additional information about an account on load. * Fetches additional information about an account on load.
*/ */
private async loadAccountInfo(agent: AtpAgent, did: string) { async loadAccountInfo(agent: BskyAgent, did: string) {
const res = await agent.api.app.bsky.actor const res = await agent.getProfile({actor: did}).catch(_e => undefined)
.getProfile({actor: did})
.catch(_e => undefined)
if (res) { if (res) {
return { return {
dispayName: res.data.displayName, dispayName: res.data.displayName,
@ -255,8 +254,8 @@ export class SessionModel {
* Helper to fetch the accounts config settings from an account. * Helper to fetch the accounts config settings from an account.
*/ */
async describeService(service: string): Promise<ServiceDescription> { async describeService(service: string): Promise<ServiceDescription> {
const agent = new AtpAgent({service}) const agent = new BskyAgent({service})
const res = await agent.api.com.atproto.server.getAccountsConfig({}) const res = await agent.com.atproto.server.describeServer({})
return res.data return res.data
} }
@ -272,7 +271,7 @@ export class SessionModel {
return false return false
} }
const agent = new AtpAgent({ const agent = new BskyAgent({
service: account.service, service: account.service,
persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => { persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.persistSession(account.service, account.did, evt, sess) this.persistSession(account.service, account.did, evt, sess)
@ -321,7 +320,7 @@ export class SessionModel {
password: string password: string
}) { }) {
this._log('SessionModel:login') this._log('SessionModel:login')
const agent = new AtpAgent({service}) const agent = new BskyAgent({service})
await agent.login({identifier, password}) await agent.login({identifier, password})
if (!agent.session) { if (!agent.session) {
throw new Error('Failed to establish session') throw new Error('Failed to establish session')
@ -355,7 +354,7 @@ export class SessionModel {
inviteCode?: string inviteCode?: string
}) { }) {
this._log('SessionModel:createAccount') this._log('SessionModel:createAccount')
const agent = new AtpAgent({service}) const agent = new BskyAgent({service})
await agent.createAccount({ await agent.createAccount({
handle, handle,
password, password,
@ -389,7 +388,7 @@ export class SessionModel {
// need to evaluate why deleting the session has caused errors at times // need to evaluate why deleting the session has caused errors at times
// -prf // -prf
/*if (this.hasSession) { /*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( this.rootStore.log.warn(
'(Minor issue) Failed to delete session on the server', '(Minor issue) Failed to delete session on the server',
e, e,
@ -415,7 +414,7 @@ export class SessionModel {
if (!sess) { if (!sess) {
return return
} }
const res = await this.rootStore.api.app.bsky.actor const res = await this.rootStore.agent
.getProfile({actor: sess.did}) .getProfile({actor: sess.did})
.catch(_e => undefined) .catch(_e => undefined)
if (res?.success) { if (res?.success) {

View File

@ -72,12 +72,12 @@ export class SuggestedPostsView {
// state transitions // state transitions
// = // =
private _xLoading() { _xLoading() {
this.isLoading = true this.isLoading = true
this.error = '' this.error = ''
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.hasLoaded = true this.hasLoaded = true
this.error = cleanError(err) this.error = cleanError(err)

View File

@ -2,7 +2,7 @@ import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {ServiceDescription} from '../session' import {ServiceDescription} from '../session'
import {DEFAULT_SERVICE} from 'state/index' import {DEFAULT_SERVICE} from 'state/index'
import {ComAtprotoAccountCreate} from '@atproto/api' import {ComAtprotoServerCreateAccount} from '@atproto/api'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import {createFullHandle} from 'lib/strings/handles' import {createFullHandle} from 'lib/strings/handles'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
@ -99,7 +99,7 @@ export class CreateAccountModel {
}) })
} catch (e: any) { } catch (e: any) {
let errMsg = e.toString() let errMsg = e.toString()
if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
errMsg = errMsg =
'Invite code not accepted. Check that you input it correctly and try again.' '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.profile = new ProfileViewModel(rootStore, {actor: params.user})
this.feed = new FeedModel(rootStore, 'author', { this.feed = new FeedModel(rootStore, 'author', {
author: params.user, actor: params.user,
limit: 10, limit: 10,
}) })
} }
@ -64,16 +64,8 @@ export class ProfileUiModel {
return this.profile.isRefreshing || this.currentView.isRefreshing return this.profile.isRefreshing || this.currentView.isRefreshing
} }
get isUser() {
return this.profile.isUser
}
get selectorItems() { get selectorItems() {
if (this.isUser) { return USER_SELECTOR_ITEMS
return USER_SELECTOR_ITEMS
} else {
return USER_SELECTOR_ITEMS
}
} }
get selectedView() { get selectedView() {

View File

@ -1,6 +1,6 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {searchProfiles, searchPosts} from 'lib/api/search' import {searchProfiles, searchPosts} from 'lib/api/search'
import {AppBskyActorProfile as Profile} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
export class SearchUIModel { export class SearchUIModel {
@ -8,7 +8,7 @@ export class SearchUIModel {
isProfilesLoading = false isProfilesLoading = false
query: string = '' query: string = ''
postUris: string[] = [] postUris: string[] = []
profiles: Profile.View[] = [] profiles: AppBskyActorDefs.ProfileView[] = []
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this) makeAutoObservable(this)
@ -34,10 +34,10 @@ export class SearchUIModel {
this.isPostsLoading = false this.isPostsLoading = false
}) })
let profiles: Profile.View[] = [] let profiles: AppBskyActorDefs.ProfileView[] = []
if (profilesSearch?.length) { if (profilesSearch?.length) {
do { 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), actors: profilesSearch.splice(0, 25).map(p => p.did),
}) })
profiles = profiles.concat(res.data.profiles) profiles = profiles.concat(res.data.profiles)

View File

@ -1,3 +1,4 @@
import {AppBskyEmbedRecord} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import {ProfileViewModel} from '../profile-view' import {ProfileViewModel} from '../profile-view'
@ -111,6 +112,7 @@ export interface ComposerOptsQuote {
displayName?: string displayName?: string
avatar?: string avatar?: string
} }
embeds?: AppBskyEmbedRecord.ViewRecord['embeds']
} }
export interface ComposerOpts { export interface ComposerOpts {
replyTo?: ComposerOptsPostRef replyTo?: ComposerOptsPostRef

View File

@ -1,5 +1,5 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {AppBskyActorRef} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import AwaitLock from 'await-lock' import AwaitLock from 'await-lock'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
@ -11,8 +11,8 @@ export class UserAutocompleteViewModel {
lock = new AwaitLock() lock = new AwaitLock()
// data // data
follows: AppBskyActorRef.WithInfo[] = [] follows: AppBskyActorDefs.ProfileViewBasic[] = []
searchRes: AppBskyActorRef.WithInfo[] = [] searchRes: AppBskyActorDefs.ProfileViewBasic[] = []
knownHandles: Set<string> = new Set() knownHandles: Set<string> = new Set()
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
@ -76,9 +76,9 @@ export class UserAutocompleteViewModel {
// internal // internal
// = // =
private async _getFollows() { async _getFollows() {
const res = await this.rootStore.api.app.bsky.graph.getFollows({ const res = await this.rootStore.agent.getFollows({
user: this.rootStore.me.did || '', actor: this.rootStore.me.did || '',
}) })
runInAction(() => { runInAction(() => {
this.follows = res.data.follows this.follows = res.data.follows
@ -88,13 +88,13 @@ export class UserAutocompleteViewModel {
}) })
} }
private async _search() { async _search() {
const res = await this.rootStore.api.app.bsky.actor.searchTypeahead({ const res = await this.rootStore.agent.searchActorsTypeahead({
term: this.prefix, term: this.prefix,
limit: 8, limit: 8,
}) })
runInAction(() => { runInAction(() => {
this.searchRes = res.data.users this.searchRes = res.data.actors
for (const u of this.searchRes) { for (const u of this.searchRes) {
this.knownHandles.add(u.handle) this.knownHandles.add(u.handle)
} }

View File

@ -1,7 +1,7 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import { import {
AppBskyGraphGetFollowers as GetFollowers, AppBskyGraphGetFollowers as GetFollowers,
AppBskyActorRef as ActorRef, AppBskyActorDefs as ActorDefs,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
@ -9,7 +9,7 @@ import {bundleAsync} from 'lib/async/bundle'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
export type FollowerItem = ActorRef.WithInfo export type FollowerItem = ActorDefs.ProfileViewBasic
export class UserFollowersViewModel { export class UserFollowersViewModel {
// state // state
@ -22,10 +22,9 @@ export class UserFollowersViewModel {
loadMoreCursor?: string loadMoreCursor?: string
// data // data
subject: ActorRef.WithInfo = { subject: ActorDefs.ProfileViewBasic = {
did: '', did: '',
handle: '', handle: '',
declaration: {cid: '', actorType: ''},
} }
followers: FollowerItem[] = [] followers: FollowerItem[] = []
@ -71,9 +70,9 @@ export class UserFollowersViewModel {
try { try {
const params = Object.assign({}, this.params, { const params = Object.assign({}, this.params, {
limit: PAGE_SIZE, 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) { if (replace) {
this._replaceAll(res) this._replaceAll(res)
} else { } else {
@ -88,13 +87,13 @@ export class UserFollowersViewModel {
// state transitions // state transitions
// = // =
private _xLoading(isRefreshing = false) { _xLoading(isRefreshing = false) {
this.isLoading = true this.isLoading = true
this.isRefreshing = isRefreshing this.isRefreshing = isRefreshing
this.error = '' this.error = ''
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
@ -107,12 +106,12 @@ export class UserFollowersViewModel {
// helper functions // helper functions
// = // =
private _replaceAll(res: GetFollowers.Response) { _replaceAll(res: GetFollowers.Response) {
this.followers = [] this.followers = []
this._appendAll(res) this._appendAll(res)
} }
private _appendAll(res: GetFollowers.Response) { _appendAll(res: GetFollowers.Response) {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.followers = this.followers.concat(res.data.followers) this.followers = this.followers.concat(res.data.followers)

View File

@ -1,7 +1,7 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import { import {
AppBskyGraphGetFollows as GetFollows, AppBskyGraphGetFollows as GetFollows,
AppBskyActorRef as ActorRef, AppBskyActorDefs as ActorDefs,
} from '@atproto/api' } from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
@ -9,7 +9,7 @@ import {bundleAsync} from 'lib/async/bundle'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
export type FollowItem = ActorRef.WithInfo export type FollowItem = ActorDefs.ProfileViewBasic
export class UserFollowsViewModel { export class UserFollowsViewModel {
// state // state
@ -22,10 +22,9 @@ export class UserFollowsViewModel {
loadMoreCursor?: string loadMoreCursor?: string
// data // data
subject: ActorRef.WithInfo = { subject: ActorDefs.ProfileViewBasic = {
did: '', did: '',
handle: '', handle: '',
declaration: {cid: '', actorType: ''},
} }
follows: FollowItem[] = [] follows: FollowItem[] = []
@ -71,9 +70,9 @@ export class UserFollowsViewModel {
try { try {
const params = Object.assign({}, this.params, { const params = Object.assign({}, this.params, {
limit: PAGE_SIZE, 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) { if (replace) {
this._replaceAll(res) this._replaceAll(res)
} else { } else {
@ -88,13 +87,13 @@ export class UserFollowsViewModel {
// state transitions // state transitions
// = // =
private _xLoading(isRefreshing = false) { _xLoading(isRefreshing = false) {
this.isLoading = true this.isLoading = true
this.isRefreshing = isRefreshing this.isRefreshing = isRefreshing
this.error = '' this.error = ''
} }
private _xIdle(err?: any) { _xIdle(err?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
@ -107,12 +106,12 @@ export class UserFollowsViewModel {
// helper functions // helper functions
// = // =
private _replaceAll(res: GetFollows.Response) { _replaceAll(res: GetFollows.Response) {
this.follows = [] this.follows = []
this._appendAll(res) this._appendAll(res)
} }
private _appendAll(res: GetFollows.Response) { _appendAll(res: GetFollows.Response) {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.follows = this.follows.concat(res.data.follows) this.follows = this.follows.concat(res.data.follows)

View File

@ -75,16 +75,14 @@ export const CreateAccount = observer(
{model.step === 3 && <Step3 model={model} />} {model.step === 3 && <Step3 model={model} />}
</View> </View>
<View style={[s.flexRow, s.pl20, s.pr20]}> <View style={[s.flexRow, s.pl20, s.pr20]}>
<TouchableOpacity onPress={onPressBackInner}> <TouchableOpacity onPress={onPressBackInner} testID="backBtn">
<Text type="xl" style={pal.link}> <Text type="xl" style={pal.link}>
Back Back
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<View style={s.flex1} /> <View style={s.flex1} />
{model.canNext ? ( {model.canNext ? (
<TouchableOpacity <TouchableOpacity testID="nextBtn" onPress={onPressNext}>
testID="createAccountButton"
onPress={onPressNext}>
{model.isProcessing ? ( {model.isProcessing ? (
<ActivityIndicator /> <ActivityIndicator />
) : ( ) : (
@ -95,7 +93,7 @@ export const CreateAccount = observer(
</TouchableOpacity> </TouchableOpacity>
) : model.didServiceDescriptionFetchFail ? ( ) : model.didServiceDescriptionFetchFail ? (
<TouchableOpacity <TouchableOpacity
testID="registerRetryButton" testID="retryConnectBtn"
onPress={onPressRetryConnect}> onPress={onPressRetryConnect}>
<Text type="xl-bold" style={[pal.link, s.pr5]}> <Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry Retry

View File

@ -60,12 +60,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
This is the company that keeps you online. This is the company that keeps you online.
</Text> </Text>
<Option <Option
testID="blueskyServerBtn"
isSelected={isDefaultSelected} isSelected={isDefaultSelected}
label="Bluesky" label="Bluesky"
help="&nbsp;(default)" help="&nbsp;(default)"
onPress={onPressDefault} onPress={onPressDefault}
/> />
<Option <Option
testID="otherServerBtn"
isSelected={!isDefaultSelected} isSelected={!isDefaultSelected}
label="Other" label="Other"
onPress={onPressOther}> onPress={onPressOther}>
@ -74,6 +76,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
Enter the address of your provider: Enter the address of your provider:
</Text> </Text>
<TextInput <TextInput
testID="customServerInput"
icon="globe" icon="globe"
placeholder="Hosting provider address" placeholder="Hosting provider address"
value={model.serviceUrl} value={model.serviceUrl}
@ -83,12 +86,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
{LOGIN_INCLUDE_DEV_SERVERS && ( {LOGIN_INCLUDE_DEV_SERVERS && (
<View style={[s.flexRow, s.mt10]}> <View style={[s.flexRow, s.mt10]}>
<Button <Button
testID="stagingServerBtn"
type="default" type="default"
style={s.mr5} style={s.mr5}
label="Staging" label="Staging"
onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)}
/> />
<Button <Button
testID="localDevServerBtn"
type="default" type="default"
label="Dev Server" label="Dev Server"
onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)}
@ -112,11 +117,13 @@ function Option({
label, label,
help, help,
onPress, onPress,
testID,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
isSelected: boolean isSelected: boolean
label: string label: string
help?: string help?: string
onPress: () => void onPress: () => void
testID?: string
}>) { }>) {
const theme = useTheme() const theme = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
@ -129,7 +136,7 @@ function Option({
return ( return (
<View style={[styles.option, pal.border]}> <View style={[styles.option, pal.border]}>
<TouchableWithoutFeedback onPress={onPress}> <TouchableWithoutFeedback onPress={onPress} testID={testID}>
<View style={styles.optionHeading}> <View style={styles.optionHeading}>
<View style={[styles.circle, pal.border]}> <View style={[styles.circle, pal.border]}>
{isSelected ? ( {isSelected ? (

View File

@ -59,6 +59,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
Email address Email address
</Text> </Text>
<TextInput <TextInput
testID="emailInput"
icon="envelope" icon="envelope"
placeholder="Enter your email address" placeholder="Enter your email address"
value={model.email} value={model.email}
@ -72,6 +73,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
Password Password
</Text> </Text>
<TextInput <TextInput
testID="passwordInput"
icon="lock" icon="lock"
placeholder="Choose your password" placeholder="Choose your password"
value={model.password} value={model.password}
@ -86,7 +88,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
Legal check Legal check
</Text> </Text>
<TouchableOpacity <TouchableOpacity
testID="registerIs13Input" testID="is13Input"
style={[styles.toggleBtn, pal.border]} style={[styles.toggleBtn, pal.border]}
onPress={() => model.setIs13(!model.is13)}> onPress={() => model.setIs13(!model.is13)}>
<View style={[pal.borderDark, styles.checkbox]}> <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" /> <StepHeader step="3" title="Your user handle" />
<View style={s.pb10}> <View style={s.pb10}>
<TextInput <TextInput
testID="handleInput"
icon="at" icon="at"
placeholder="eg alice" placeholder="eg alice"
value={model.handle} value={model.handle}

View File

@ -13,7 +13,7 @@ import {
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import AtpAgent from '@atproto/api' import {BskyAgent} from '@atproto/api'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
import {Text} from '../../util/text/Text' import {Text} from '../../util/text/Text'
import {UserAvatar} from '../../util/UserAvatar' import {UserAvatar} from '../../util/UserAvatar'
@ -506,8 +506,8 @@ const ForgotPasswordForm = ({
setIsProcessing(true) setIsProcessing(true)
try { try {
const agent = new AtpAgent({service: serviceUrl}) const agent = new BskyAgent({service: serviceUrl})
await agent.api.com.atproto.account.requestPasswordReset({email}) await agent.com.atproto.server.requestPasswordReset({email})
onEmailSent() onEmailSent()
} catch (e: any) { } catch (e: any) {
const errMsg = e.toString() const errMsg = e.toString()
@ -648,8 +648,8 @@ const SetNewPasswordForm = ({
setIsProcessing(true) setIsProcessing(true)
try { try {
const agent = new AtpAgent({service: serviceUrl}) const agent = new BskyAgent({service: serviceUrl})
await agent.api.com.atproto.account.resetPassword({ await agent.com.atproto.server.resetPassword({
token: resetCode, token: resetCode,
password, 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 {observer} from 'mobx-react-lite'
import { import {
ActivityIndicator, ActivityIndicator,
@ -13,6 +13,7 @@ import {
} from 'react-native' } from 'react-native'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {RichText} from '@atproto/api'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
import {ExternalEmbed} from './ExternalEmbed' import {ExternalEmbed} from './ExternalEmbed'
@ -30,11 +31,11 @@ import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
import {OpenCameraBtn} from './photos/OpenCameraBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn'
import {SelectedPhotos} from './photos/SelectedPhotos' import {SelectedPhotos} from './photos/SelectedPhotos'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
import {useExternalLinkFetch} from './useExternalLinkFetch' import {useExternalLinkFetch} from './useExternalLinkFetch'
import {isDesktopWeb} from 'platform/detection' import {isDesktopWeb} from 'platform/detection'
const MAX_TEXT_LENGTH = 256 const MAX_GRAPHEME_LENGTH = 300
export const ComposePost = observer(function ComposePost({ export const ComposePost = observer(function ComposePost({
replyTo, replyTo,
@ -50,17 +51,23 @@ export const ComposePost = observer(function ComposePost({
const {track} = useAnalytics() const {track} = useAnalytics()
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const textInput = useRef<TextInputRef>(null) const textInput = React.useRef<TextInputRef>(null)
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = React.useState(false)
const [processingState, setProcessingState] = useState('') const [processingState, setProcessingState] = React.useState('')
const [error, setError] = useState('') const [error, setError] = React.useState('')
const [text, setText] = useState('') const [richtext, setRichText] = React.useState(new RichText({text: ''}))
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( const graphemeLength = React.useMemo(
() => richtext.graphemeLength,
[richtext],
)
const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>(
initQuote, initQuote,
) )
const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>(
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]) new Set(),
)
const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([])
const autocompleteView = React.useMemo<UserAutocompleteViewModel>( const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
() => new UserAutocompleteViewModel(store), () => new UserAutocompleteViewModel(store),
@ -78,11 +85,11 @@ export const ComposePost = observer(function ComposePost({
}, [textInput, onClose]) }, [textInput, onClose])
// initial setup // initial setup
useEffect(() => { React.useEffect(() => {
autocompleteView.setup() autocompleteView.setup()
}, [autocompleteView]) }, [autocompleteView])
useEffect(() => { React.useEffect(() => {
// HACK // HACK
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
// -prf // -prf
@ -132,18 +139,18 @@ export const ComposePost = observer(function ComposePost({
if (isProcessing) { if (isProcessing) {
return return
} }
if (text.length > MAX_TEXT_LENGTH) { if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
return return
} }
setError('') 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?') setError('Did you want to say anything?')
return false return false
} }
setIsProcessing(true) setIsProcessing(true)
try { try {
await apilib.post(store, { await apilib.post(store, {
rawText: text, rawText: richtext.text,
replyTo: replyTo?.uri, replyTo: replyTo?.uri,
images: selectedPhotos, images: selectedPhotos,
quote: quote, quote: quote,
@ -172,7 +179,7 @@ export const ComposePost = observer(function ComposePost({
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
}, [ }, [
isProcessing, isProcessing,
text, richtext,
setError, setError,
setIsProcessing, setIsProcessing,
replyTo, replyTo,
@ -187,7 +194,7 @@ export const ComposePost = observer(function ComposePost({
track, track,
]) ])
const canPost = text.length <= MAX_TEXT_LENGTH const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
const selectTextInputPlaceholder = replyTo const selectTextInputPlaceholder = replyTo
? 'Write your reply' ? 'Write your reply'
@ -215,7 +222,7 @@ export const ComposePost = observer(function ComposePost({
</View> </View>
) : canPost ? ( ) : canPost ? (
<TouchableOpacity <TouchableOpacity
testID="composerPublishButton" testID="composerPublishBtn"
onPress={onPressPublish}> onPress={onPressPublish}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
@ -271,42 +278,41 @@ export const ComposePost = observer(function ComposePost({
<UserAvatar avatar={store.me.avatar} size={50} /> <UserAvatar avatar={store.me.avatar} size={50} />
<TextInput <TextInput
ref={textInput} ref={textInput}
text={text} richtext={richtext}
placeholder={selectTextInputPlaceholder} placeholder={selectTextInputPlaceholder}
suggestedLinks={suggestedLinks} suggestedLinks={suggestedLinks}
autocompleteView={autocompleteView} autocompleteView={autocompleteView}
onTextChanged={setText} setRichText={setRichText}
onPhotoPasted={onPhotoPasted} onPhotoPasted={onPhotoPasted}
onSuggestedLinksChanged={setSuggestedLinks} onSuggestedLinksChanged={setSuggestedLinks}
onError={setError} onError={setError}
/> />
</View> </View>
{quote ? (
<View style={s.mt5}>
<QuoteEmbed quote={quote} />
</View>
) : undefined}
<SelectedPhotos <SelectedPhotos
selectedPhotos={selectedPhotos} selectedPhotos={selectedPhotos}
onSelectPhotos={onSelectPhotos} onSelectPhotos={onSelectPhotos}
/> />
{!selectedPhotos.length && extLink && ( {selectedPhotos.length === 0 && extLink && (
<ExternalEmbed <ExternalEmbed
link={extLink} link={extLink}
onRemove={() => setExtLink(undefined)} onRemove={() => setExtLink(undefined)}
/> />
)} )}
{quote ? (
<View style={s.mt5}>
<QuoteEmbed quote={quote} />
</View>
) : undefined}
</ScrollView> </ScrollView>
{!extLink && {!extLink &&
selectedPhotos.length === 0 && selectedPhotos.length === 0 &&
suggestedLinks.size > 0 && suggestedLinks.size > 0 ? (
!quote ? (
<View style={s.mb5}> <View style={s.mb5}>
{Array.from(suggestedLinks).map(url => ( {Array.from(suggestedLinks).map(url => (
<TouchableOpacity <TouchableOpacity
key={`suggested-${url}`} key={`suggested-${url}`}
testID="addLinkCardBtn"
style={[pal.borderDark, styles.addExtLinkBtn]} style={[pal.borderDark, styles.addExtLinkBtn]}
onPress={() => onPressAddLinkCard(url)}> onPress={() => onPressAddLinkCard(url)}>
<Text style={pal.text}> <Text style={pal.text}>
@ -318,17 +324,17 @@ export const ComposePost = observer(function ComposePost({
) : null} ) : null}
<View style={[pal.border, styles.bottomBar]}> <View style={[pal.border, styles.bottomBar]}>
<SelectPhotoBtn <SelectPhotoBtn
enabled={!quote && selectedPhotos.length < 4} enabled={selectedPhotos.length < 4}
selectedPhotos={selectedPhotos} selectedPhotos={selectedPhotos}
onSelectPhotos={setSelectedPhotos} onSelectPhotos={setSelectedPhotos}
/> />
<OpenCameraBtn <OpenCameraBtn
enabled={!quote && selectedPhotos.length < 4} enabled={selectedPhotos.length < 4}
selectedPhotos={selectedPhotos} selectedPhotos={selectedPhotos}
onSelectPhotos={setSelectedPhotos} onSelectPhotos={setSelectedPhotos}
/> />
<View style={s.flex1} /> <View style={s.flex1} />
<CharProgress count={text.length} /> <CharProgress count={graphemeLength} />
</View> </View>
</SafeAreaView> </SafeAreaView>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
@ -408,6 +414,7 @@ const styles = StyleSheet.create({
borderRadius: 24, borderRadius: 24,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
marginHorizontal: 10,
marginBottom: 4, marginBottom: 4,
}, },
bottomBar: { bottomBar: {

View File

@ -8,26 +8,24 @@ import ProgressPie from 'react-native-progress/Pie'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
const MAX_TEXT_LENGTH = 256 const MAX_LENGTH = 300
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH const DANGER_LENGTH = MAX_LENGTH
export function CharProgress({count}: {count: number}) { export function CharProgress({count}: {count: number}) {
const pal = usePalette('default') const pal = usePalette('default')
const textColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.text const textColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.text
const circleColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.link const circleColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.link
return ( return (
<> <>
<Text style={[s.mr10, {color: textColor}]}> <Text style={[s.mr10, {color: textColor}]}>{MAX_LENGTH - count}</Text>
{MAX_TEXT_LENGTH - count}
</Text>
<View> <View>
{count > DANGER_TEXT_LENGTH ? ( {count > DANGER_LENGTH ? (
<ProgressPie <ProgressPie
size={30} size={30}
borderWidth={4} borderWidth={4}
borderColor={circleColor} borderColor={circleColor}
color={circleColor} color={circleColor}
progress={Math.min((count - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH, 1)} progress={Math.min((count - MAX_LENGTH) / MAX_LENGTH, 1)}
/> />
) : ( ) : (
<ProgressCircle <ProgressCircle
@ -35,7 +33,7 @@ export function CharProgress({count}: {count: number}) {
borderWidth={1} borderWidth={1}
borderColor={pal.colors.border} borderColor={pal.colors.border}
color={circleColor} color={circleColor}
progress={count / MAX_TEXT_LENGTH} progress={count / MAX_LENGTH}
/> />
)} )}
</View> </View>

View File

@ -76,7 +76,11 @@ export function OpenCameraBtn({
hitSlop={HITSLOP}> hitSlop={HITSLOP}>
<FontAwesomeIcon <FontAwesomeIcon
icon="camera" icon="camera"
style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} style={
(enabled
? pal.link
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
}
size={24} size={24}
/> />
</TouchableOpacity> </TouchableOpacity>

View File

@ -86,7 +86,11 @@ export function SelectPhotoBtn({
hitSlop={HITSLOP}> hitSlop={HITSLOP}>
<FontAwesomeIcon <FontAwesomeIcon
icon={['far', 'image']} icon={['far', 'image']}
style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} style={
(enabled
? pal.link
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
}
size={24} size={24}
/> />
</TouchableOpacity> </TouchableOpacity>

View File

@ -9,13 +9,13 @@ import PasteInput, {
PastedFile, PastedFile,
PasteInputRef, PasteInputRef,
} from '@mattermost/react-native-paste-input' } from '@mattermost/react-native-paste-input'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import isEqual from 'lodash.isequal' import isEqual from 'lodash.isequal'
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
import {Autocomplete} from './mobile/Autocomplete' import {Autocomplete} from './mobile/Autocomplete'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
import {getImageDim} from 'lib/media/manip' import {getImageDim} from 'lib/media/manip'
import {cropAndCompressFlow} from 'lib/media/picker' import {cropAndCompressFlow} from 'lib/media/picker'
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
@ -33,11 +33,11 @@ export interface TextInputRef {
} }
interface TextInputProps { interface TextInputProps {
text: string richtext: RichText
placeholder: string placeholder: string
suggestedLinks: Set<string> suggestedLinks: Set<string>
autocompleteView: UserAutocompleteViewModel autocompleteView: UserAutocompleteViewModel
onTextChanged: (v: string) => void setRichText: (v: RichText) => void
onPhotoPasted: (uri: string) => void onPhotoPasted: (uri: string) => void
onSuggestedLinksChanged: (uris: Set<string>) => void onSuggestedLinksChanged: (uris: Set<string>) => void
onError: (err: string) => void onError: (err: string) => void
@ -51,11 +51,11 @@ interface Selection {
export const TextInput = React.forwardRef( export const TextInput = React.forwardRef(
( (
{ {
text, richtext,
placeholder, placeholder,
suggestedLinks, suggestedLinks,
autocompleteView, autocompleteView,
onTextChanged, setRichText,
onPhotoPasted, onPhotoPasted,
onSuggestedLinksChanged, onSuggestedLinksChanged,
onError, onError,
@ -92,7 +92,9 @@ export const TextInput = React.forwardRef(
const onChangeText = React.useCallback( const onChangeText = React.useCallback(
(newText: string) => { (newText: string) => {
onTextChanged(newText) const newRt = new RichText({text: newText})
newRt.detectFacetsWithoutResolution()
setRichText(newRt)
const prefix = getMentionAt( const prefix = getMentionAt(
newText, newText,
@ -105,20 +107,21 @@ export const TextInput = React.forwardRef(
autocompleteView.setActive(false) autocompleteView.setActive(false)
} }
const ents = extractEntities(newText)?.filter( const set: Set<string> = new Set()
ent => ent.type === 'link', if (newRt.facets) {
) for (const facet of newRt.facets) {
const set = new Set(ents ? ents.map(e => e.value) : []) for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) {
set.add(feature.uri)
}
}
}
}
if (!isEqual(set, suggestedLinks)) { if (!isEqual(set, suggestedLinks)) {
onSuggestedLinksChanged(set) onSuggestedLinksChanged(set)
} }
}, },
[ [setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged],
onTextChanged,
autocompleteView,
suggestedLinks,
onSuggestedLinksChanged,
],
) )
const onPaste = React.useCallback( const onPaste = React.useCallback(
@ -159,31 +162,35 @@ export const TextInput = React.forwardRef(
const onSelectAutocompleteItem = React.useCallback( const onSelectAutocompleteItem = React.useCallback(
(item: string) => { (item: string) => {
onChangeText( onChangeText(
insertMentionAt(text, textInputSelection.current?.start || 0, item), insertMentionAt(
richtext.text,
textInputSelection.current?.start || 0,
item,
),
) )
autocompleteView.setActive(false) autocompleteView.setActive(false)
}, },
[onChangeText, text, autocompleteView], [onChangeText, richtext, autocompleteView],
) )
const textDecorated = React.useMemo(() => { const textDecorated = React.useMemo(() => {
let i = 0 let i = 0
return detectLinkables(text).map(v => { return Array.from(richtext.segments()).map(segment => {
if (typeof v === 'string') { if (!segment.facet) {
return ( return (
<Text key={i++} style={[pal.text, styles.textInputFormatting]}> <Text key={i++} style={[pal.text, styles.textInputFormatting]}>
{v} {segment.text}
</Text> </Text>
) )
} else { } else {
return ( return (
<Text key={i++} style={[pal.link, styles.textInputFormatting]}> <Text key={i++} style={[pal.link, styles.textInputFormatting]}>
{v.link} {segment.text}
</Text> </Text>
) )
} }
}) })
}, [text, pal.link, pal.text]) }, [richtext, pal.link, pal.text])
return ( return (
<View style={styles.container}> <View style={styles.container}>

View File

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {RichText} from '@atproto/api'
import {useEditor, EditorContent, JSONContent} from '@tiptap/react' import {useEditor, EditorContent, JSONContent} from '@tiptap/react'
import {Document} from '@tiptap/extension-document' import {Document} from '@tiptap/extension-document'
import {Link} from '@tiptap/extension-link' import {Link} from '@tiptap/extension-link'
@ -17,11 +18,11 @@ export interface TextInputRef {
} }
interface TextInputProps { interface TextInputProps {
text: string richtext: RichText
placeholder: string placeholder: string
suggestedLinks: Set<string> suggestedLinks: Set<string>
autocompleteView: UserAutocompleteViewModel autocompleteView: UserAutocompleteViewModel
onTextChanged: (v: string) => void setRichText: (v: RichText) => void
onPhotoPasted: (uri: string) => void onPhotoPasted: (uri: string) => void
onSuggestedLinksChanged: (uris: Set<string>) => void onSuggestedLinksChanged: (uris: Set<string>) => void
onError: (err: string) => void onError: (err: string) => void
@ -30,11 +31,11 @@ interface TextInputProps {
export const TextInput = React.forwardRef( export const TextInput = React.forwardRef(
( (
{ {
text, richtext,
placeholder, placeholder,
suggestedLinks, suggestedLinks,
autocompleteView, autocompleteView,
onTextChanged, setRichText,
// onPhotoPasted, TODO // onPhotoPasted, TODO
onSuggestedLinksChanged, onSuggestedLinksChanged,
}: // onError, TODO }: // onError, TODO
@ -60,15 +61,15 @@ export const TextInput = React.forwardRef(
}), }),
Text, Text,
], ],
content: text, content: richtext.text.toString(),
autofocus: true, autofocus: true,
editable: true, editable: true,
injectCSS: true, injectCSS: true,
onUpdate({editor: editorProp}) { onUpdate({editor: editorProp}) {
const json = editorProp.getJSON() const json = editorProp.getJSON()
const newText = editorJsonToText(json).trim() const newRt = new RichText({text: editorJsonToText(json).trim()})
onTextChanged(newText) setRichText(newRt)
const newSuggestedLinks = new Set(editorJsonToLinks(json)) const newSuggestedLinks = new Set(editorJsonToLinks(json))
if (!isEqual(newSuggestedLinks, suggestedLinks)) { if (!isEqual(newSuggestedLinks, suggestedLinks)) {

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' 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 {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@ -12,9 +12,9 @@ export const SuggestedFollows = ({
}: { }: {
title: string title: string
suggestions: ( suggestions: (
| AppBskyActorRef.WithInfo | AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileView
| RefWithInfoAndFollowers | RefWithInfoAndFollowers
| AppBskyActorProfile.View
)[] )[]
}) => { }) => {
const pal = usePalette('default') const pal = usePalette('default')
@ -28,7 +28,6 @@ export const SuggestedFollows = ({
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn
key={item.did} key={item.did}
did={item.did} did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle} handle={item.handle}
displayName={item.displayName} displayName={item.displayName}
avatar={item.avatar} avatar={item.avatar}
@ -36,12 +35,12 @@ export const SuggestedFollows = ({
noBorder noBorder
description={ description={
item.description item.description
? (item as AppBskyActorProfile.View).description ? (item as AppBskyActorDefs.ProfileView).description
: '' : ''
} }
followers={ followers={
item.followers item.followers
? (item.followers as AppBskyActorProfile.View[]) ? (item.followers as AppBskyActorDefs.ProfileView[])
: undefined : undefined
} }
/> />

View File

@ -105,7 +105,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
track('EditHandle:SetNewHandle') track('EditHandle:SetNewHandle')
const newHandle = isCustom ? handle : createFullHandle(handle, userDomain) const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
store.log.debug(`Updating handle to ${newHandle}`) store.log.debug(`Updating handle to ${newHandle}`)
await store.api.com.atproto.handle.update({ await store.agent.updateHandle({
handle: newHandle, handle: newHandle,
}) })
store.shell.closeModal() store.shell.closeModal()
@ -310,7 +310,7 @@ function CustomHandleForm({
try { try {
setIsVerifying(true) setIsVerifying(true)
setError('') 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) { if (res.data.did === store.me.did) {
setCanSave(true) setCanSave(true)
} else { } else {
@ -331,7 +331,7 @@ function CustomHandleForm({
canSave, canSave,
onPressSave, onPressSave,
store.log, store.log,
store.api, store.agent,
]) ])
// rendering // rendering

View File

@ -39,7 +39,7 @@ export function Component({
} }
} }
return ( 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> <Text style={styles.title}>{title}</Text>
{typeof message === 'string' ? ( {typeof message === 'string' ? (
<Text style={styles.description}>{message}</Text> <Text style={styles.description}>{message}</Text>
@ -56,7 +56,7 @@ export function Component({
<ActivityIndicator /> <ActivityIndicator />
</View> </View>
) : ( ) : (
<TouchableOpacity style={s.mt10} onPress={onPress}> <TouchableOpacity testID="confirmBtn" style={s.mt10} onPress={onPress}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -32,7 +32,7 @@ export function Component({}: {}) {
setError('') setError('')
setIsProcessing(true) setIsProcessing(true)
try { try {
await store.api.com.atproto.account.requestDelete() await store.agent.com.atproto.server.requestAccountDelete()
setIsEmailSent(true) setIsEmailSent(true)
} catch (e: any) { } catch (e: any) {
setError(cleanError(e)) setError(cleanError(e))
@ -43,7 +43,7 @@ export function Component({}: {}) {
setError('') setError('')
setIsProcessing(true) setIsProcessing(true)
try { try {
await store.api.com.atproto.account.delete({ await store.agent.com.atproto.server.deleteAccount({
did: store.me.did, did: store.me.did,
password, password,
token: confirmCode, token: confirmCode,

View File

@ -123,7 +123,7 @@ export function Component({
} }
return ( return (
<View style={[s.flex1, pal.view]}> <View style={[s.flex1, pal.view]} testID="editProfileModal">
<ScrollView style={styles.inner}> <ScrollView style={styles.inner}>
<Text style={[styles.title, pal.text]}>Edit my profile</Text> <Text style={[styles.title, pal.text]}>Edit my profile</Text>
<View style={styles.photos}> <View style={styles.photos}>
@ -147,6 +147,7 @@ export function Component({
<View> <View>
<Text style={[styles.label, pal.text]}>Display Name</Text> <Text style={[styles.label, pal.text]}>Display Name</Text>
<TextInput <TextInput
testID="editProfileDisplayNameInput"
style={[styles.textInput, pal.text]} style={[styles.textInput, pal.text]}
placeholder="e.g. Alice Roberts" placeholder="e.g. Alice Roberts"
placeholderTextColor={colors.gray4} placeholderTextColor={colors.gray4}
@ -157,6 +158,7 @@ export function Component({
<View style={s.pb10}> <View style={s.pb10}>
<Text style={[styles.label, pal.text]}>Description</Text> <Text style={[styles.label, pal.text]}>Description</Text>
<TextInput <TextInput
testID="editProfileDescriptionInput"
style={[styles.textArea, pal.text]} style={[styles.textArea, pal.text]}
placeholder="e.g. Artist, dog-lover, and memelord." placeholder="e.g. Artist, dog-lover, and memelord."
placeholderTextColor={colors.gray4} placeholderTextColor={colors.gray4}
@ -171,7 +173,10 @@ export function Component({
<ActivityIndicator /> <ActivityIndicator />
</View> </View>
) : ( ) : (
<TouchableOpacity style={s.mt10} onPress={onPressSave}> <TouchableOpacity
testID="editProfileSaveBtn"
style={s.mt10}
onPress={onPressSave}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}
@ -181,7 +186,10 @@ export function Component({
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
)} )}
<TouchableOpacity style={s.mt5} onPress={onPressCancel}> <TouchableOpacity
testID="editProfileCancelBtn"
style={s.mt5}
onPress={onPressCancel}>
<View style={[styles.btn]}> <View style={[styles.btn]}>
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text> <Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
</View> </View>

View File

@ -5,7 +5,7 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import {ComAtprotoReportReasonType} from '@atproto/api' import {ComAtprotoModerationDefs} from '@atproto/api'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {s, colors, gradients} from 'lib/styles' import {s, colors, gradients} from 'lib/styles'
@ -39,16 +39,16 @@ export function Component({did}: {did: string}) {
setIsProcessing(true) setIsProcessing(true)
try { try {
// NOTE: we should update the lexicon of reasontype to include more options -prf // NOTE: we should update the lexicon of reasontype to include more options -prf
let reasonType = ComAtprotoReportReasonType.OTHER let reasonType = ComAtprotoModerationDefs.REASONOTHER
if (issue === 'spam') { if (issue === 'spam') {
reasonType = ComAtprotoReportReasonType.SPAM reasonType = ComAtprotoModerationDefs.REASONSPAM
} }
const reason = ITEMS.find(item => item.key === issue)?.label || '' const reason = ITEMS.find(item => item.key === issue)?.label || ''
await store.api.com.atproto.report.create({ await store.agent.com.atproto.moderation.createReport({
reasonType, reasonType,
reason, reason,
subject: { subject: {
$type: 'com.atproto.repo.repoRef', $type: 'com.atproto.admin.defs#repoRef',
did, did,
}, },
}) })
@ -61,12 +61,18 @@ export function Component({did}: {did: string}) {
} }
} }
return ( 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.text, styles.title]}>Report account</Text>
<Text style={[pal.textLight, styles.description]}> <Text style={[pal.textLight, styles.description]}>
What is the issue with this account? What is the issue with this account?
</Text> </Text>
<RadioGroup items={ITEMS} onSelect={onSelectIssue} /> <RadioGroup
testID="reportAccountRadios"
items={ITEMS}
onSelect={onSelectIssue}
/>
{error ? ( {error ? (
<View style={s.mt10}> <View style={s.mt10}>
<ErrorMessage message={error} /> <ErrorMessage message={error} />
@ -77,7 +83,10 @@ export function Component({did}: {did: string}) {
<ActivityIndicator /> <ActivityIndicator />
</View> </View>
) : issue ? ( ) : issue ? (
<TouchableOpacity style={s.mt10} onPress={onPress}> <TouchableOpacity
testID="sendReportBtn"
style={s.mt10}
onPress={onPress}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -5,7 +5,7 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import {ComAtprotoReportReasonType} from '@atproto/api' import {ComAtprotoModerationDefs} from '@atproto/api'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {s, colors, gradients} from 'lib/styles' import {s, colors, gradients} from 'lib/styles'
@ -46,16 +46,16 @@ export function Component({
setIsProcessing(true) setIsProcessing(true)
try { try {
// NOTE: we should update the lexicon of reasontype to include more options -prf // NOTE: we should update the lexicon of reasontype to include more options -prf
let reasonType = ComAtprotoReportReasonType.OTHER let reasonType = ComAtprotoModerationDefs.REASONOTHER
if (issue === 'spam') { if (issue === 'spam') {
reasonType = ComAtprotoReportReasonType.SPAM reasonType = ComAtprotoModerationDefs.REASONSPAM
} }
const reason = ITEMS.find(item => item.key === issue)?.label || '' const reason = ITEMS.find(item => item.key === issue)?.label || ''
await store.api.com.atproto.report.create({ await store.agent.createModerationReport({
reasonType, reasonType,
reason, reason,
subject: { subject: {
$type: 'com.atproto.repo.recordRef', $type: 'com.atproto.repo.strongRef',
uri: postUri, uri: postUri,
cid: postCid, cid: postCid,
}, },
@ -69,12 +69,16 @@ export function Component({
} }
} }
return ( 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.text, styles.title]}>Report post</Text>
<Text style={[pal.textLight, styles.description]}> <Text style={[pal.textLight, styles.description]}>
What is the issue with this post? What is the issue with this post?
</Text> </Text>
<RadioGroup items={ITEMS} onSelect={onSelectIssue} /> <RadioGroup
testID="reportPostRadios"
items={ITEMS}
onSelect={onSelectIssue}
/>
{error ? ( {error ? (
<View style={s.mt10}> <View style={s.mt10}>
<ErrorMessage message={error} /> <ErrorMessage message={error} />
@ -85,7 +89,10 @@ export function Component({
<ActivityIndicator /> <ActivityIndicator />
</View> </View>
) : issue ? ( ) : issue ? (
<TouchableOpacity style={s.mt10} onPress={onPress}> <TouchableOpacity
testID="sendReportBtn"
style={s.mt10}
onPress={onPress}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -26,22 +26,28 @@ export function Component({
} }
return ( return (
<View style={[s.flex1, pal.view, styles.container]}> <View testID="repostModal" style={[s.flex1, pal.view, styles.container]}>
<View style={s.pb20}> <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} /> <RepostIcon strokeWidth={2} size={24} style={s.blue3} />
<Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
{!isReposted ? 'Repost' : 'Undo repost'} {!isReposted ? 'Repost' : 'Undo repost'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.actionBtn]} onPress={onQuote}> <TouchableOpacity
testID="quoteBtn"
style={[styles.actionBtn]}
onPress={onQuote}>
<FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} /> <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} />
<Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
Quote Post Quote Post
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<TouchableOpacity onPress={onPress}> <TouchableOpacity testID="cancelBtn" onPress={onPress}>
<LinearGradient <LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]} colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}} start={{x: 0, y: 0}}

View File

@ -47,10 +47,10 @@ export const FeedItem = observer(function FeedItem({
const pal = usePalette('default') const pal = usePalette('default')
const [isAuthorsExpanded, setAuthorsExpanded] = React.useState<boolean>(false) const [isAuthorsExpanded, setAuthorsExpanded] = React.useState<boolean>(false)
const itemHref = React.useMemo(() => { const itemHref = React.useMemo(() => {
if (item.isUpvote || item.isRepost) { if (item.isLike || item.isRepost) {
const urip = new AtUri(item.subjectUri) const urip = new AtUri(item.subjectUri)
return `/profile/${urip.host}/post/${urip.rkey}` return `/profile/${urip.host}/post/${urip.rkey}`
} else if (item.isFollow || item.isAssertion) { } else if (item.isFollow) {
return `/profile/${item.author.handle}` return `/profile/${item.author.handle}`
} else if (item.isReply) { } else if (item.isReply) {
const urip = new AtUri(item.uri) const urip = new AtUri(item.uri)
@ -59,9 +59,9 @@ export const FeedItem = observer(function FeedItem({
return '' return ''
}, [item]) }, [item])
const itemTitle = React.useMemo(() => { const itemTitle = React.useMemo(() => {
if (item.isUpvote || item.isRepost) { if (item.isLike || item.isRepost) {
return 'Post' return 'Post'
} else if (item.isFollow || item.isAssertion) { } else if (item.isFollow) {
return item.author.handle return item.author.handle
} else if (item.isReply) { } else if (item.isReply) {
return 'Post' return 'Post'
@ -77,7 +77,7 @@ export const FeedItem = observer(function FeedItem({
return <View /> return <View />
} }
if (item.isReply || item.isMention) { if (item.isReply || item.isMention || item.isQuote) {
if (item.additionalPost?.error) { if (item.additionalPost?.error) {
// hide errors - it doesnt help the user to show them // hide errors - it doesnt help the user to show them
return <View /> return <View />
@ -103,7 +103,7 @@ export const FeedItem = observer(function FeedItem({
let action = '' let action = ''
let icon: Props['icon'] | 'HeartIconSolid' let icon: Props['icon'] | 'HeartIconSolid'
let iconStyle: Props['style'] = [] let iconStyle: Props['style'] = []
if (item.isUpvote) { if (item.isLike) {
action = 'liked your post' action = 'liked your post'
icon = 'HeartIconSolid' icon = 'HeartIconSolid'
iconStyle = [ iconStyle = [
@ -114,9 +114,6 @@ export const FeedItem = observer(function FeedItem({
action = 'reposted your post' action = 'reposted your post'
icon = 'retweet' icon = 'retweet'
iconStyle = [s.green3 as FontAwesomeIconStyle] iconStyle = [s.green3 as FontAwesomeIconStyle]
} else if (item.isReply) {
action = 'replied to your post'
icon = ['far', 'comment']
} else if (item.isFollow) { } else if (item.isFollow) {
action = 'followed you' action = 'followed you'
icon = 'user-plus' icon = 'user-plus'
@ -208,7 +205,7 @@ export const FeedItem = observer(function FeedItem({
</View> </View>
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
{item.isUpvote || item.isRepost ? ( {item.isLike || item.isRepost || item.isQuote ? (
<AdditionalPostText additionalPost={item.additionalPost} /> <AdditionalPostText additionalPost={item.additionalPost} />
) : ( ) : (
<></> <></>
@ -352,9 +349,9 @@ function AdditionalPostText({
return <View /> return <View />
} }
const text = additionalPost.thread?.postRecord.text const text = additionalPost.thread?.postRecord.text
const images = ( const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed)
additionalPost.thread.post.embed as AppBskyEmbedImages.Presented ? additionalPost.thread.post.embed.images
)?.images : undefined
return ( return (
<> <>
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} {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' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
export const FeedsTabBar = observer( export const FeedsTabBar = observer(
(props: RenderTabBarFnProps & {onPressSelected: () => void}) => { (
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) => {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const interp = useAnimatedValue(0) const interp = useAnimatedValue(0)
@ -32,7 +34,10 @@ export const FeedsTabBar = observer(
return ( return (
<Animated.View style={[pal.view, styles.tabBar, transform]}> <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} /> <UserAvatar avatar={store.me.avatar} size={30} />
</TouchableOpacity> </TouchableOpacity>
<TabBar <TabBar

View File

@ -20,6 +20,7 @@ interface Props {
initialPage?: number initialPage?: number
renderTabBar: RenderTabBarFn renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void onPageSelected?: (index: number) => void
testID?: string
} }
export const Pager = ({ export const Pager = ({
children, children,
@ -27,6 +28,7 @@ export const Pager = ({
initialPage = 0, initialPage = 0,
renderTabBar, renderTabBar,
onPageSelected, onPageSelected,
testID,
}: React.PropsWithChildren<Props>) => { }: React.PropsWithChildren<Props>) => {
const [selectedPage, setSelectedPage] = React.useState(0) const [selectedPage, setSelectedPage] = React.useState(0)
const position = useAnimatedValue(0) const position = useAnimatedValue(0)
@ -49,7 +51,7 @@ export const Pager = ({
) )
return ( return (
<View> <View testID={testID}>
{tabBarPosition === 'top' && {tabBarPosition === 'top' &&
renderTabBar({ renderTabBar({
selectedPage, selectedPage,

View File

@ -15,6 +15,7 @@ interface Layout {
} }
export interface TabBarProps { export interface TabBarProps {
testID?: string
selectedPage: number selectedPage: number
items: string[] items: string[]
position: Animated.Value position: Animated.Value
@ -26,6 +27,7 @@ export interface TabBarProps {
} }
export function TabBar({ export function TabBar({
testID,
selectedPage, selectedPage,
items, items,
position, position,
@ -92,12 +94,15 @@ export function TabBar({
} }
return ( 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]} /> <Animated.View style={[styles.indicator, indicatorStyle]} />
{items.map((item, i) => { {items.map((item, i) => {
const selected = i === selectedPage const selected = i === selectedPage
return ( return (
<TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}> <TouchableWithoutFeedback
key={i}
testID={testID ? `${testID}-${item}` : undefined}
onPress={() => onPressItem(i)}>
<View <View
style={ style={
indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom

View File

@ -2,24 +2,18 @@ import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
import {CenteredView, FlatList} from '../util/Views' 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 {ErrorMessage} from '../util/error/ErrorMessage'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
export const PostVotedBy = observer(function PostVotedBy({ export const PostLikedBy = observer(function PostVotedBy({uri}: {uri: string}) {
uri,
direction,
}: {
uri: string
direction: 'up' | 'down'
}) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const view = React.useMemo( const view = React.useMemo(
() => new VotesViewModel(store, {uri, direction}), () => new LikesViewModel(store, {uri}),
[store, uri, direction], [store, uri],
) )
useEffect(() => { useEffect(() => {
@ -55,11 +49,10 @@ export const PostVotedBy = observer(function PostVotedBy({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: VoteItem}) => ( const renderItem = ({item}: {item: LikeItem}) => (
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn
key={item.actor.did} key={item.actor.did}
did={item.actor.did} did={item.actor.did}
declarationCid={item.actor.declaration.cid}
handle={item.actor.handle} handle={item.actor.handle}
displayName={item.actor.displayName} displayName={item.actor.displayName}
avatar={item.actor.avatar} avatar={item.actor.avatar}
@ -68,7 +61,7 @@ export const PostVotedBy = observer(function PostVotedBy({
) )
return ( return (
<FlatList <FlatList
data={view.votes} data={view.likes}
keyExtractor={item => item.actor.did} keyExtractor={item => item.actor.did}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl

View File

@ -64,7 +64,6 @@ export const PostRepostedBy = observer(function PostRepostedBy({
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn
key={item.did} key={item.did}
did={item.did} did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle} handle={item.handle}
displayName={item.displayName} displayName={item.displayName}
avatar={item.avatar} avatar={item.avatar}

View File

@ -1,17 +1,30 @@
import React, {useRef} from 'react' import React, {useRef} from 'react'
import {observer} from 'mobx-react-lite' 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 {CenteredView, FlatList} from '../util/Views'
import { import {
PostThreadViewModel, PostThreadViewModel,
PostThreadViewPostModel, PostThreadViewPostModel,
} from 'state/models/post-thread-view' } from 'state/models/post-thread-view'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {PostThreadItem} from './PostThreadItem' import {PostThreadItem} from './PostThreadItem'
import {ComposePrompt} from '../composer/Prompt' import {ComposePrompt} from '../composer/Prompt'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection' import {isDesktopWeb} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette' 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 REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
const BOTTOM_BORDER = { const BOTTOM_BORDER = {
@ -32,6 +45,7 @@ export const PostThread = observer(function PostThread({
const pal = usePalette('default') const pal = usePalette('default')
const ref = useRef<FlatList>(null) const ref = useRef<FlatList>(null)
const [isRefreshing, setIsRefreshing] = React.useState(false) const [isRefreshing, setIsRefreshing] = React.useState(false)
const navigation = useNavigation<NavigationProp>()
const posts = React.useMemo(() => { const posts = React.useMemo(() => {
if (view.thread) { if (view.thread) {
return Array.from(flattenThread(view.thread)).concat([BOTTOM_BORDER]) return Array.from(flattenThread(view.thread)).concat([BOTTOM_BORDER])
@ -41,6 +55,7 @@ export const PostThread = observer(function PostThread({
// events // events
// = // =
const onRefresh = React.useCallback(async () => { const onRefresh = React.useCallback(async () => {
setIsRefreshing(true) setIsRefreshing(true)
try { try {
@ -50,6 +65,7 @@ export const PostThread = observer(function PostThread({
} }
setIsRefreshing(false) setIsRefreshing(false)
}, [view, setIsRefreshing]) }, [view, setIsRefreshing])
const onLayout = React.useCallback(() => { const onLayout = React.useCallback(() => {
const index = posts.findIndex(post => post._isHighlightedPost) const index = posts.findIndex(post => post._isHighlightedPost)
if (index !== -1) { if (index !== -1) {
@ -60,6 +76,7 @@ export const PostThread = observer(function PostThread({
}) })
} }
}, [posts, ref]) }, [posts, ref])
const onScrollToIndexFailed = React.useCallback( const onScrollToIndexFailed = React.useCallback(
(info: { (info: {
index: number index: number
@ -73,6 +90,15 @@ export const PostThread = observer(function PostThread({
}, },
[ref], [ref],
) )
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
const renderItem = React.useCallback( const renderItem = React.useCallback(
({item}: {item: YieldedItem}) => { ({item}: {item: YieldedItem}) => {
if (item === REPLY_PROMPT) { if (item === REPLY_PROMPT) {
@ -104,6 +130,30 @@ export const PostThread = observer(function PostThread({
// error // error
// = // =
if (view.hasError) { 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 ( return (
<CenteredView> <CenteredView>
<ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
@ -159,12 +209,18 @@ function* flattenThread(
yield* flattenThread(reply as PostThreadViewPostModel) 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 post._hasMore = true
} }
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
notFoundContainer: {
margin: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderRadius: 6,
},
bottomBorder: { bottomBorder: {
borderBottomWidth: 1, borderBottomWidth: 1,
}, },

View File

@ -19,7 +19,7 @@ import {ago} from 'lib/strings/time'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {PostMeta} from '../util/PostMeta' import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/PostEmbeds' import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/PostCtrls' import {PostCtrls} from '../util/PostCtrls'
import {PostMutedWrapper} from '../util/PostMuted' import {PostMutedWrapper} from '../util/PostMuted'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
@ -38,7 +38,7 @@ export const PostThreadItem = observer(function PostThreadItem({
const store = useStores() const store = useStores()
const [deleted, setDeleted] = React.useState(false) const [deleted, setDeleted] = React.useState(false)
const record = item.postRecord 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 itemUri = item.post.uri
const itemCid = item.post.cid const itemCid = item.post.cid
@ -49,11 +49,11 @@ export const PostThreadItem = observer(function PostThreadItem({
const itemTitle = `Post by ${item.post.author.handle}` const itemTitle = `Post by ${item.post.author.handle}`
const authorHref = `/profile/${item.post.author.handle}` const authorHref = `/profile/${item.post.author.handle}`
const authorTitle = 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) 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]) }, [item.post.uri, item.post.author.handle])
const upvotesTitle = 'Likes on this post' const likesTitle = 'Likes on this post'
const repostsHref = React.useMemo(() => { const repostsHref = React.useMemo(() => {
const urip = new AtUri(item.post.uri) const urip = new AtUri(item.post.uri)
return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by` return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by`
@ -80,10 +80,10 @@ export const PostThreadItem = observer(function PostThreadItem({
.toggleRepost() .toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e)) .catch(e => store.log.error('Failed to toggle repost', e))
}, [item, store]) }, [item, store])
const onPressToggleUpvote = React.useCallback(() => { const onPressToggleLike = React.useCallback(() => {
return item return item
.toggleUpvote() .toggleLike()
.catch(e => store.log.error('Failed to toggle upvote', e)) .catch(e => store.log.error('Failed to toggle like', e))
}, [item, store]) }, [item, store])
const onCopyPostText = React.useCallback(() => { const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '') Clipboard.setString(record?.text || '')
@ -125,153 +125,151 @@ export const PostThreadItem = observer(function PostThreadItem({
if (item._isHighlightedPost) { if (item._isHighlightedPost) {
return ( return (
<> <View
<View testID={`postThreadItem-by-${item.post.author.handle}`}
style={[ style={[
styles.outer, styles.outer,
styles.outerHighlighted, styles.outerHighlighted,
{borderTopColor: pal.colors.border}, {borderTopColor: pal.colors.border},
pal.view, pal.view,
]}> ]}>
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<Link href={authorHref} title={authorTitle} asAnchor> <Link href={authorHref} title={authorTitle} asAnchor>
<UserAvatar size={52} avatar={item.post.author.avatar} /> <UserAvatar size={52} avatar={item.post.author.avatar} />
</Link> </Link>
</View> </View>
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<View style={[styles.meta, styles.metaExpandedLine1]}> <View style={[styles.meta, styles.metaExpandedLine1]}>
<View style={[s.flexRow, s.alignBaseline]}> <View style={[s.flexRow, s.alignBaseline]}>
<Link
style={styles.metaItem}
href={authorHref}
title={authorTitle}>
<Text
type="xl-bold"
style={[pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{item.post.author.displayName || item.post.author.handle}
</Text>
</Link>
<Text type="md" style={[styles.metaItem, pal.textLight]}>
&middot; {ago(item.post.indexedAt)}
</Text>
</View>
<View style={s.flex1} />
<PostDropdownBtn
style={styles.metaItem}
itemUri={itemUri}
itemCid={itemCid}
itemHref={itemHref}
itemTitle={itemTitle}
isAuthor={item.post.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onDeletePost={onDeletePost}>
<FontAwesomeIcon
icon="ellipsis-h"
size={14}
style={[s.mt2, s.mr5, pal.textLight]}
/>
</PostDropdownBtn>
</View>
<View style={styles.meta}>
<Link <Link
style={styles.metaItem} style={styles.metaItem}
href={authorHref} href={authorHref}
title={authorTitle}> title={authorTitle}>
<Text type="md" style={[pal.textLight]} numberOfLines={1}> <Text
@{item.post.author.handle} type="xl-bold"
style={[pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{item.post.author.displayName || item.post.author.handle}
</Text> </Text>
</Link> </Link>
<Text type="md" style={[styles.metaItem, pal.textLight]}>
&middot; {ago(item.post.indexedAt)}
</Text>
</View> </View>
</View> <View style={s.flex1} />
</View> <PostDropdownBtn
<View style={[s.pl10, s.pr10, s.pb10]}> testID="postDropdownBtn"
{item.richText?.text ? ( style={styles.metaItem}
<View
style={[
styles.postTextContainer,
styles.postTextLargeContainer,
]}>
<RichText
type="post-text-lg"
richText={item.richText}
lineHeight={1.3}
/>
</View>
) : undefined}
<PostEmbeds embed={item.post.embed} style={s.mb10} />
{item._isHighlightedPost && hasEngagement ? (
<View style={[styles.expandedInfo, pal.border]}>
{item.post.repostCount ? (
<Link
style={styles.expandedInfoItem}
href={repostsHref}
title={repostsTitle}>
<Text type="lg" style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{item.post.repostCount}
</Text>{' '}
{pluralize(item.post.repostCount, 'repost')}
</Text>
</Link>
) : (
<></>
)}
{item.post.upvoteCount ? (
<Link
style={styles.expandedInfoItem}
href={upvotesHref}
title={upvotesTitle}>
<Text type="lg" style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{item.post.upvoteCount}
</Text>{' '}
{pluralize(item.post.upvoteCount, 'like')}
</Text>
</Link>
) : (
<></>
)}
</View>
) : (
<></>
)}
<View style={[s.pl10, s.pb5]}>
<PostCtrls
big
itemUri={itemUri} itemUri={itemUri}
itemCid={itemCid} itemCid={itemCid}
itemHref={itemHref} itemHref={itemHref}
itemTitle={itemTitle} itemTitle={itemTitle}
author={{
avatar: item.post.author.avatar!,
handle: item.post.author.handle,
displayName: item.post.author.displayName!,
}}
text={item.richText?.text || record.text}
indexedAt={item.post.indexedAt}
isAuthor={item.post.author.did === store.me.did} isAuthor={item.post.author.did === store.me.did}
isReposted={!!item.post.viewer.repost}
isUpvoted={!!item.post.viewer.upvote}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
onCopyPostText={onCopyPostText} onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate} onOpenTranslate={onOpenTranslate}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}>
/> <FontAwesomeIcon
icon="ellipsis-h"
size={14}
style={[s.mt2, s.mr5, pal.textLight]}
/>
</PostDropdownBtn>
</View>
<View style={styles.meta}>
<Link
style={styles.metaItem}
href={authorHref}
title={authorTitle}>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
@{item.post.author.handle}
</Text>
</Link>
</View> </View>
</View> </View>
</View> </View>
</> <View style={[s.pl10, s.pr10, s.pb10]}>
{item.richText?.text ? (
<View
style={[styles.postTextContainer, styles.postTextLargeContainer]}>
<RichText
type="post-text-lg"
richText={item.richText}
lineHeight={1.3}
/>
</View>
) : undefined}
<PostEmbeds embed={item.post.embed} style={s.mb10} />
{item._isHighlightedPost && hasEngagement ? (
<View style={[styles.expandedInfo, pal.border]}>
{item.post.repostCount ? (
<Link
style={styles.expandedInfoItem}
href={repostsHref}
title={repostsTitle}>
<Text testID="repostCount" type="lg" style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{item.post.repostCount}
</Text>{' '}
{pluralize(item.post.repostCount, 'repost')}
</Text>
</Link>
) : (
<></>
)}
{item.post.likeCount ? (
<Link
style={styles.expandedInfoItem}
href={likesHref}
title={likesTitle}>
<Text testID="likeCount" type="lg" style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{item.post.likeCount}
</Text>{' '}
{pluralize(item.post.likeCount, 'like')}
</Text>
</Link>
) : (
<></>
)}
</View>
) : (
<></>
)}
<View style={[s.pl10, s.pb5]}>
<PostCtrls
big
itemUri={itemUri}
itemCid={itemCid}
itemHref={itemHref}
itemTitle={itemTitle}
author={{
avatar: item.post.author.avatar!,
handle: item.post.author.handle,
displayName: item.post.author.displayName!,
}}
text={item.richText?.text || record.text}
indexedAt={item.post.indexedAt}
isAuthor={item.post.author.did === store.me.did}
isReposted={!!item.post.viewer?.repost}
isLiked={!!item.post.viewer?.like}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onDeletePost={onDeletePost}
/>
</View>
</View>
</View>
) )
} else { } else {
return ( return (
<PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}> <PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}>
<Link <Link
testID={`postThreadItem-by-${item.post.author.handle}`}
style={[styles.outer, {borderTopColor: pal.colors.border}, pal.view]} style={[styles.outer, {borderTopColor: pal.colors.border}, pal.view]}
href={itemHref} href={itemHref}
title={itemTitle} title={itemTitle}
@ -305,7 +303,6 @@ export const PostThreadItem = observer(function PostThreadItem({
timestamp={item.post.indexedAt} timestamp={item.post.indexedAt}
postHref={itemHref} postHref={itemHref}
did={item.post.author.did} did={item.post.author.did}
declarationCid={item.post.author.declaration.cid}
/> />
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>
@ -333,12 +330,12 @@ export const PostThreadItem = observer(function PostThreadItem({
isAuthor={item.post.author.did === store.me.did} isAuthor={item.post.author.did === store.me.did}
replyCount={item.post.replyCount} replyCount={item.post.replyCount}
repostCount={item.post.repostCount} repostCount={item.post.repostCount}
upvoteCount={item.post.upvoteCount} likeCount={item.post.likeCount}
isReposted={!!item.post.viewer.repost} isReposted={!!item.post.viewer?.repost}
isUpvoted={!!item.post.viewer.upvote} isLiked={!!item.post.viewer?.like}
onPressReply={onPressReply} onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost} onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote} onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText} onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate} onOpenTranslate={onOpenTranslate}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}

View File

@ -15,7 +15,7 @@ import {PostThreadViewModel} from 'state/models/post-thread-view'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta' import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/PostEmbeds' import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/PostCtrls' import {PostCtrls} from '../util/PostCtrls'
import {PostMutedWrapper} from '../util/PostMuted' import {PostMutedWrapper} from '../util/PostMuted'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@ -118,10 +118,10 @@ export const Post = observer(function Post({
.toggleRepost() .toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e)) .catch(e => store.log.error('Failed to toggle repost', e))
} }
const onPressToggleUpvote = () => { const onPressToggleLike = () => {
return item return item
.toggleUpvote() .toggleLike()
.catch(e => store.log.error('Failed to toggle upvote', e)) .catch(e => store.log.error('Failed to toggle like', e))
} }
const onCopyPostText = () => { const onCopyPostText = () => {
Clipboard.setString(record.text) Clipboard.setString(record.text)
@ -166,7 +166,6 @@ export const Post = observer(function Post({
timestamp={item.post.indexedAt} timestamp={item.post.indexedAt}
postHref={itemHref} postHref={itemHref}
did={item.post.author.did} did={item.post.author.did}
declarationCid={item.post.author.declaration.cid}
/> />
{replyAuthorDid !== '' && ( {replyAuthorDid !== '' && (
<View style={[s.flexRow, s.mb2, s.alignCenter]}> <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} isAuthor={item.post.author.did === store.me.did}
replyCount={item.post.replyCount} replyCount={item.post.replyCount}
repostCount={item.post.repostCount} repostCount={item.post.repostCount}
upvoteCount={item.post.upvoteCount} likeCount={item.post.likeCount}
isReposted={!!item.post.viewer.repost} isReposted={!!item.post.viewer?.repost}
isUpvoted={!!item.post.viewer.upvote} isLiked={!!item.post.viewer?.like}
onPressReply={onPressReply} onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost} onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote} onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText} onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate} onOpenTranslate={onOpenTranslate}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}

View File

@ -128,6 +128,7 @@ export const Feed = observer(function Feed({
<View testID={testID} style={style}> <View testID={testID} style={style}>
{data.length > 0 && ( {data.length > 0 && (
<FlatList <FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef} ref={scrollElRef}
data={data} data={data}
keyExtractor={item => item._reactKey} keyExtractor={item => item._reactKey}

View File

@ -13,7 +13,7 @@ import {Text} from '../util/text/Text'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta' import {PostMeta} from '../util/PostMeta'
import {PostCtrls} from '../util/PostCtrls' import {PostCtrls} from '../util/PostCtrls'
import {PostEmbeds} from '../util/PostEmbeds' import {PostEmbeds} from '../util/post-embeds'
import {PostMutedWrapper} from '../util/PostMuted' import {PostMutedWrapper} from '../util/PostMuted'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
@ -79,11 +79,11 @@ export const FeedItem = observer(function ({
.toggleRepost() .toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e)) .catch(e => store.log.error('Failed to toggle repost', e))
} }
const onPressToggleUpvote = () => { const onPressToggleLike = () => {
track('FeedItem:PostLike') track('FeedItem:PostLike')
return item return item
.toggleUpvote() .toggleLike()
.catch(e => store.log.error('Failed to toggle upvote', e)) .catch(e => store.log.error('Failed to toggle like', e))
} }
const onCopyPostText = () => { const onCopyPostText = () => {
Clipboard.setString(record?.text || '') Clipboard.setString(record?.text || '')
@ -127,7 +127,12 @@ export const FeedItem = observer(function ({
return ( return (
<PostMutedWrapper isMuted={isMuted}> <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 && ( {isThreadChild && (
<View <View
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
@ -189,7 +194,6 @@ export const FeedItem = observer(function ({
timestamp={item.post.indexedAt} timestamp={item.post.indexedAt}
postHref={itemHref} postHref={itemHref}
did={item.post.author.did} did={item.post.author.did}
declarationCid={item.post.author.declaration.cid}
showFollowBtn={showFollowBtn} showFollowBtn={showFollowBtn}
/> />
{!isThreadChild && replyAuthorDid !== '' && ( {!isThreadChild && replyAuthorDid !== '' && (
@ -239,12 +243,12 @@ export const FeedItem = observer(function ({
isAuthor={item.post.author.did === store.me.did} isAuthor={item.post.author.did === store.me.did}
replyCount={item.post.replyCount} replyCount={item.post.replyCount}
repostCount={item.post.repostCount} repostCount={item.post.repostCount}
upvoteCount={item.post.upvoteCount} likeCount={item.post.likeCount}
isReposted={!!item.post.viewer.repost} isReposted={!!item.post.viewer?.repost}
isUpvoted={!!item.post.viewer.upvote} isLiked={!!item.post.viewer?.like}
onPressReply={onPressReply} onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost} onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote} onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText} onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate} onOpenTranslate={onOpenTranslate}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}

View File

@ -2,19 +2,16 @@ import React from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {Button, ButtonType} from '../util/forms/Button' import {Button, ButtonType} from '../util/forms/Button'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import * as apilib from 'lib/api/index'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
const FollowButton = observer( const FollowButton = observer(
({ ({
type = 'inverted', type = 'inverted',
did, did,
declarationCid,
onToggleFollow, onToggleFollow,
}: { }: {
type?: ButtonType type?: ButtonType
did: string did: string
declarationCid: string
onToggleFollow?: (v: boolean) => void onToggleFollow?: (v: boolean) => void
}) => { }) => {
const store = useStores() const store = useStores()
@ -23,7 +20,7 @@ const FollowButton = observer(
const onToggleFollowInner = async () => { const onToggleFollowInner = async () => {
if (store.me.follows.isFollowing(did)) { if (store.me.follows.isFollowing(did)) {
try { try {
await apilib.unfollow(store, store.me.follows.getFollowUri(did)) await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
store.me.follows.removeFollow(did) store.me.follows.removeFollow(did)
onToggleFollow?.(false) onToggleFollow?.(false)
} catch (e: any) { } catch (e: any) {
@ -32,7 +29,7 @@ const FollowButton = observer(
} }
} else { } else {
try { try {
const res = await apilib.follow(store, did, declarationCid) const res = await store.agent.follow(did)
store.me.follows.addFollow(did, res.uri) store.me.follows.addFollow(did, res.uri)
onToggleFollow?.(true) onToggleFollow?.(true)
} catch (e: any) { } catch (e: any) {

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {AppBskyActorProfile} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
@ -11,6 +11,7 @@ import {useStores} from 'state/index'
import FollowButton from './FollowButton' import FollowButton from './FollowButton'
export function ProfileCard({ export function ProfileCard({
testID,
handle, handle,
displayName, displayName,
avatar, avatar,
@ -21,6 +22,7 @@ export function ProfileCard({
followers, followers,
renderButton, renderButton,
}: { }: {
testID?: string
handle: string handle: string
displayName?: string displayName?: string
avatar?: string avatar?: string
@ -28,12 +30,13 @@ export function ProfileCard({
isFollowedBy?: boolean isFollowedBy?: boolean
noBg?: boolean noBg?: boolean
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorProfile.View[] | undefined followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: () => JSX.Element renderButton?: () => JSX.Element
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<Link <Link
testID={testID}
style={[ style={[
styles.outer, styles.outer,
pal.border, pal.border,
@ -106,7 +109,6 @@ export function ProfileCard({
export const ProfileCardWithFollowBtn = observer( export const ProfileCardWithFollowBtn = observer(
({ ({
did, did,
declarationCid,
handle, handle,
displayName, displayName,
avatar, avatar,
@ -117,7 +119,6 @@ export const ProfileCardWithFollowBtn = observer(
followers, followers,
}: { }: {
did: string did: string
declarationCid: string
handle: string handle: string
displayName?: string displayName?: string
avatar?: string avatar?: string
@ -125,7 +126,7 @@ export const ProfileCardWithFollowBtn = observer(
isFollowedBy?: boolean isFollowedBy?: boolean
noBg?: boolean noBg?: boolean
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorProfile.View[] | undefined followers?: AppBskyActorDefs.ProfileView[] | undefined
}) => { }) => {
const store = useStores() const store = useStores()
const isMe = store.me.handle === handle const isMe = store.me.handle === handle
@ -140,11 +141,7 @@ export const ProfileCardWithFollowBtn = observer(
noBg={noBg} noBg={noBg}
noBorder={noBorder} noBorder={noBorder}
followers={followers} followers={followers}
renderButton={ renderButton={isMe ? undefined : () => <FollowButton did={did} />}
isMe
? undefined
: () => <FollowButton did={did} declarationCid={declarationCid} />
}
/> />
) )
}, },

View File

@ -19,7 +19,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const view = React.useMemo( const view = React.useMemo(
() => new UserFollowersViewModel(store, {user: name}), () => new UserFollowersViewModel(store, {actor: name}),
[store, name], [store, name],
) )
@ -64,7 +64,6 @@ export const ProfileFollowers = observer(function ProfileFollowers({
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn
key={item.did} key={item.did}
did={item.did} did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle} handle={item.handle}
displayName={item.displayName} displayName={item.displayName}
avatar={item.avatar} avatar={item.avatar}

View File

@ -16,7 +16,7 @@ export const ProfileFollows = observer(function ProfileFollows({
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const view = React.useMemo( const view = React.useMemo(
() => new UserFollowsViewModel(store, {user: name}), () => new UserFollowsViewModel(store, {actor: name}),
[store, name], [store, name],
) )
@ -61,7 +61,6 @@ export const ProfileFollows = observer(function ProfileFollows({
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn
key={item.did} key={item.did}
did={item.did} did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle} handle={item.handle}
displayName={item.displayName} displayName={item.displayName}
avatar={item.avatar} avatar={item.avatar}

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