diff --git a/.detoxrc.js b/.detoxrc.js index fc9cf042..1fe578e7 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -3,7 +3,7 @@ module.exports = { testRunner: { args: { $0: 'jest', - config: 'e2e/jest.config.js', + config: '__e2e__/jest.config.js', }, jest: { setupTimeout: 120000, @@ -12,15 +12,16 @@ module.exports = { apps: { 'ios.debug': { type: 'ios.app', - binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/app.app', + binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/bluesky.app', build: - 'xcodebuild -workspace ios/app.xcworkspace -scheme app -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build', + 'xcodebuild -workspace ios/bluesky.xcworkspace -scheme bluesky -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build', }, 'ios.release': { type: 'ios.app', - binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/app.app', + binaryPath: + 'ios/build/Build/Products/Release-iphonesimulator/bluesky.app', build: - 'xcodebuild -workspace ios/app.xcworkspace -scheme app -configuration Release -sdk iphonesimulator -derivedDataPath ios/build', + 'xcodebuild -workspace ios/bluesky.xcworkspace -scheme bluesky -configuration Release -sdk iphonesimulator -derivedDataPath ios/build', }, 'android.debug': { type: 'android.apk', diff --git a/README.md b/README.md index 8ae03e70..c6c72c03 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ - iOS: `yarn ios` - Android: `yarn android` - Web: `yarn web` +- Run e2e tests + - Start in various console tabs: + - `yarn e2e:server` + - `yarn e2e:metro` + - Run once: `yarn e2e:build` + - Each test run: `yarn e2e:run` - Tips - `npx react-native info` Checks what has been installed. - On M1 macs, [you need to exclude "arm64" from the target architectures](https://stackoverflow.com/a/65399525) diff --git a/e2e/jest.config.js b/__e2e__/jest.config.js similarity index 88% rename from e2e/jest.config.js rename to __e2e__/jest.config.js index 3472f716..80c2ad5b 100644 --- a/e2e/jest.config.js +++ b/__e2e__/jest.config.js @@ -1,7 +1,7 @@ /** @type {import('@jest/types').Config.InitialOptions} */ module.exports = { rootDir: '..', - testMatch: ['/e2e/**/*.test.js'], + testMatch: ['/__e2e__/**/*.test.ts'], testTimeout: 120000, maxWorkers: 1, globalSetup: 'detox/runners/jest/globalSetup', diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts new file mode 100644 index 00000000..7a2be606 --- /dev/null +++ b/__e2e__/mock-server.ts @@ -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() diff --git a/__e2e__/tests/composer.test.ts b/__e2e__/tests/composer.test.ts new file mode 100644 index 00000000..afc23cc1 --- /dev/null +++ b/__e2e__/tests/composer.test.ts @@ -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() + }) +}) diff --git a/__e2e__/tests/create-account.test.ts b/__e2e__/tests/create-account.test.ts new file mode 100644 index 00000000..7b2e00fb --- /dev/null +++ b/__e2e__/tests/create-account.test.ts @@ -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() + }) +}) diff --git a/__e2e__/tests/home-screen.test.ts b/__e2e__/tests/home-screen.test.ts new file mode 100644 index 00000000..1ec1774f --- /dev/null +++ b/__e2e__/tests/home-screen.test.ts @@ -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() + }) +}) diff --git a/__e2e__/tests/login.test.ts b/__e2e__/tests/login.test.ts new file mode 100644 index 00000000..788016db --- /dev/null +++ b/__e2e__/tests/login.test.ts @@ -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') + }) +}) diff --git a/__e2e__/tests/profile-screen.test.ts b/__e2e__/tests/profile-screen.test.ts new file mode 100644 index 00000000..e1b6dcaf --- /dev/null +++ b/__e2e__/tests/profile-screen.test.ts @@ -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() + }) +}) diff --git a/__e2e__/tests/search-screen.test.ts b/__e2e__/tests/search-screen.test.ts new file mode 100644 index 00000000..093d97c8 --- /dev/null +++ b/__e2e__/tests/search-screen.test.ts @@ -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() + }) +}) diff --git a/__e2e__/tests/shell.test.ts b/__e2e__/tests/shell.test.ts new file mode 100644 index 00000000..5cfd4277 --- /dev/null +++ b/__e2e__/tests/shell.test.ts @@ -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() + }) +}) diff --git a/__e2e__/tests/thread-screen.test.ts b/__e2e__/tests/thread-screen.test.ts new file mode 100644 index 00000000..f84c339c --- /dev/null +++ b/__e2e__/tests/thread-screen.test.ts @@ -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() + }) +}) diff --git a/__e2e__/util.ts b/__e2e__/util.ts new file mode 100644 index 00000000..78d9f9f5 --- /dev/null +++ b/__e2e__/util.ts @@ -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)) diff --git a/__tests__/lib/link-meta.test.ts b/__tests__/lib/link-meta.test.ts index ce7da415..8af14628 100644 --- a/__tests__/lib/link-meta.test.ts +++ b/__tests__/lib/link-meta.test.ts @@ -4,14 +4,14 @@ import { getLikelyType, } from '../../src/lib/link-meta/link-meta' import {exampleComHtml} from './__mocks__/exampleComHtml' -import AtpAgent from '@atproto/api' +import {BskyAgent} from '@atproto/api' import {DEFAULT_SERVICE, RootStoreModel} from '../../src/state' describe('getLinkMeta', () => { let rootStore: RootStoreModel beforeEach(() => { - rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE})) + rootStore = new RootStoreModel(new BskyAgent({service: DEFAULT_SERVICE})) }) const inputs = [ diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 4f6fd62d..f25bd02a 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -7,172 +7,10 @@ import { } from '../../src/lib/strings/url-helpers' import {pluralize, enforceLen} from '../../src/lib/strings/helpers' import {ago} from '../../src/lib/strings/time' -import { - extractEntities, - detectLinkables, -} from '../../src/lib/strings/rich-text-detection' +import {detectLinkables} from '../../src/lib/strings/rich-text-detection' import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles' import {cleanError} from '../../src/lib/strings/errors' -describe('extractEntities', () => { - const knownHandles = new Set(['handle.com', 'full123.test-of-chars']) - const inputs = [ - 'no mention', - '@handle.com middle end', - 'start @handle.com end', - 'start middle @handle.com', - '@handle.com @handle.com @handle.com', - '@full123.test-of-chars', - 'not@right', - '@handle.com!@#$chars', - '@handle.com\n@handle.com', - 'parenthetical (@handle.com)', - 'start https://middle.com end', - 'start https://middle.com/foo/bar end', - 'start https://middle.com/foo/bar?baz=bux end', - 'start https://middle.com/foo/bar?baz=bux#hash end', - 'https://start.com/foo/bar?baz=bux#hash middle end', - 'start middle https://end.com/foo/bar?baz=bux#hash', - 'https://newline1.com\nhttps://newline2.com', - 'start middle.com end', - 'start middle.com/foo/bar end', - 'start middle.com/foo/bar?baz=bux end', - 'start middle.com/foo/bar?baz=bux#hash end', - 'start.com/foo/bar?baz=bux#hash middle end', - 'start middle end.com/foo/bar?baz=bux#hash', - 'newline1.com\nnewline2.com', - 'not.. a..url ..here', - 'e.g.', - 'something-cool.jpg', - 'website.com.jpg', - 'e.g./foo', - 'website.com.jpg/foo', - 'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/', - 'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/ ', - 'https://foo.com https://bar.com/whatever https://baz.com', - 'punctuation https://foo.com, https://bar.com/whatever; https://baz.com.', - 'parenthentical (https://foo.com)', - 'except for https://foo.com/thing_(cool)', - ] - interface Output { - type: string - value: string - noScheme?: boolean - } - const outputs: Output[][] = [ - [], - [{type: 'mention', value: 'handle.com'}], - [{type: 'mention', value: 'handle.com'}], - [{type: 'mention', value: 'handle.com'}], - [ - {type: 'mention', value: 'handle.com'}, - {type: 'mention', value: 'handle.com'}, - {type: 'mention', value: 'handle.com'}, - ], - [ - { - type: 'mention', - value: 'full123.test-of-chars', - }, - ], - [], - [{type: 'mention', value: 'handle.com'}], - [ - {type: 'mention', value: 'handle.com'}, - {type: 'mention', value: 'handle.com'}, - ], - [{type: 'mention', value: 'handle.com'}], - [{type: 'link', value: 'https://middle.com'}], - [{type: 'link', value: 'https://middle.com/foo/bar'}], - [{type: 'link', value: 'https://middle.com/foo/bar?baz=bux'}], - [{type: 'link', value: 'https://middle.com/foo/bar?baz=bux#hash'}], - [{type: 'link', value: 'https://start.com/foo/bar?baz=bux#hash'}], - [{type: 'link', value: 'https://end.com/foo/bar?baz=bux#hash'}], - [ - {type: 'link', value: 'https://newline1.com'}, - {type: 'link', value: 'https://newline2.com'}, - ], - [{type: 'link', value: 'middle.com', noScheme: true}], - [{type: 'link', value: 'middle.com/foo/bar', noScheme: true}], - [{type: 'link', value: 'middle.com/foo/bar?baz=bux', noScheme: true}], - [{type: 'link', value: 'middle.com/foo/bar?baz=bux#hash', noScheme: true}], - [{type: 'link', value: 'start.com/foo/bar?baz=bux#hash', noScheme: true}], - [{type: 'link', value: 'end.com/foo/bar?baz=bux#hash', noScheme: true}], - [ - {type: 'link', value: 'newline1.com', noScheme: true}, - {type: 'link', value: 'newline2.com', noScheme: true}, - ], - [], - [], - [], - [], - [], - [], - [ - { - type: 'link', - value: - 'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/', - }, - ], - [ - { - type: 'link', - value: - 'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/', - }, - ], - [ - {type: 'link', value: 'https://foo.com'}, - {type: 'link', value: 'https://bar.com/whatever'}, - {type: 'link', value: 'https://baz.com'}, - ], - [ - {type: 'link', value: 'https://foo.com'}, - {type: 'link', value: 'https://bar.com/whatever'}, - {type: 'link', value: 'https://baz.com'}, - ], - [{type: 'link', value: 'https://foo.com'}], - [{type: 'link', value: 'https://foo.com/thing_(cool)'}], - ] - it('correctly handles a set of text inputs', () => { - for (let i = 0; i < inputs.length; i++) { - const input = inputs[i] - const result = extractEntities(input, knownHandles) - if (!outputs[i].length) { - expect(result).toBeFalsy() - } else if (outputs[i].length && !result) { - expect(result).toBeTruthy() - } else if (result) { - expect(result.length).toBe(outputs[i].length) - for (let j = 0; j < outputs[i].length; j++) { - expect(result[j].type).toEqual(outputs[i][j].type) - if (outputs[i][j].noScheme) { - expect(result[j].value).toEqual(`https://${outputs[i][j].value}`) - } else { - expect(result[j].value).toEqual(outputs[i][j].value) - } - if (outputs[i]?.[j].type === 'mention') { - expect( - input.slice(result[j].index.start, result[j].index.end), - ).toBe(`@${result[j].value}`) - } else { - if (!outputs[i]?.[j].noScheme) { - expect( - input.slice(result[j].index.start, result[j].index.end), - ).toBe(result[j].value) - } else { - expect( - input.slice(result[j].index.start, result[j].index.end), - ).toBe(result[j].value.slice('https://'.length)) - } - } - } - } - } - }) -}) - describe('detectLinkables', () => { const inputs = [ 'no linkable', diff --git a/__tests__/lib/strings/rich-text-sanitize.ts b/__tests__/lib/strings/rich-text-sanitize.ts deleted file mode 100644 index d0bbae5e..00000000 --- a/__tests__/lib/strings/rich-text-sanitize.ts +++ /dev/null @@ -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) -} diff --git a/__tests__/lib/strings/rich-text.ts b/__tests__/lib/strings/rich-text.ts deleted file mode 100644 index e52ac6ce..00000000 --- a/__tests__/lib/strings/rich-text.ts +++ /dev/null @@ -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') - }) -}) diff --git a/app.json b/app.json index a4749144..0e5bb2e0 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "bluesky", "slug": "bluesky", - "version": "1.10.0", + "version": "1.11.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", diff --git a/e2e/tests/happyPath.test.js b/e2e/tests/happyPath.test.js deleted file mode 100644 index 4176cecb..00000000 --- a/e2e/tests/happyPath.test.js +++ /dev/null @@ -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() - }) -}) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 761ec737..8f9e5f90 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -22,7 +22,7 @@ PODS: - EXMediaLibrary (15.2.3): - ExpoModulesCore - React-Core - - Expo (48.0.7): + - Expo (48.0.9): - ExpoModulesCore - expo-dev-client (2.1.5): - EXManifests @@ -102,7 +102,7 @@ PODS: - ExpoModulesCore - ExpoLocalization (14.1.1): - ExpoModulesCore - - ExpoModulesCore (1.2.5): + - ExpoModulesCore (1.2.6): - React-Core - React-RCTAppDelegate - ReactCommon/turbomodule/core @@ -110,19 +110,19 @@ PODS: - ExpoModulesCore - React-Core - EXUpdatesInterface (0.9.1) - - FBLazyVector (0.71.3) - - FBReactNativeSpec (0.71.3): + - FBLazyVector (0.71.4) + - FBReactNativeSpec (0.71.4): - RCT-Folly (= 2021.07.22.00) - - RCTRequired (= 0.71.3) - - RCTTypeSafety (= 0.71.3) - - React-Core (= 0.71.3) - - React-jsi (= 0.71.3) - - ReactCommon/turbomodule/core (= 0.71.3) + - RCTRequired (= 0.71.4) + - RCTTypeSafety (= 0.71.4) + - React-Core (= 0.71.4) + - React-jsi (= 0.71.4) + - ReactCommon/turbomodule/core (= 0.71.4) - fmt (6.2.1) - glog (0.3.5) - - hermes-engine (0.71.3): - - hermes-engine/Pre-built (= 0.71.3) - - hermes-engine/Pre-built (0.71.3) + - hermes-engine (0.71.4): + - hermes-engine/Pre-built (= 0.71.4) + - hermes-engine/Pre-built (0.71.4) - libevent (2.1.12) - libwebp (1.2.4): - libwebp/demux (= 1.2.4) @@ -150,26 +150,26 @@ PODS: - fmt (~> 6.2.1) - glog - libevent - - RCTRequired (0.71.3) - - RCTTypeSafety (0.71.3): - - FBLazyVector (= 0.71.3) - - RCTRequired (= 0.71.3) - - React-Core (= 0.71.3) - - React (0.71.3): - - React-Core (= 0.71.3) - - React-Core/DevSupport (= 0.71.3) - - React-Core/RCTWebSocket (= 0.71.3) - - React-RCTActionSheet (= 0.71.3) - - React-RCTAnimation (= 0.71.3) - - React-RCTBlob (= 0.71.3) - - React-RCTImage (= 0.71.3) - - React-RCTLinking (= 0.71.3) - - React-RCTNetwork (= 0.71.3) - - React-RCTSettings (= 0.71.3) - - React-RCTText (= 0.71.3) - - React-RCTVibration (= 0.71.3) - - React-callinvoker (0.71.3) - - React-Codegen (0.71.3): + - RCTRequired (0.71.4) + - RCTTypeSafety (0.71.4): + - FBLazyVector (= 0.71.4) + - RCTRequired (= 0.71.4) + - React-Core (= 0.71.4) + - React (0.71.4): + - React-Core (= 0.71.4) + - React-Core/DevSupport (= 0.71.4) + - React-Core/RCTWebSocket (= 0.71.4) + - React-RCTActionSheet (= 0.71.4) + - React-RCTAnimation (= 0.71.4) + - React-RCTBlob (= 0.71.4) + - React-RCTImage (= 0.71.4) + - React-RCTLinking (= 0.71.4) + - React-RCTNetwork (= 0.71.4) + - React-RCTSettings (= 0.71.4) + - React-RCTText (= 0.71.4) + - React-RCTVibration (= 0.71.4) + - React-callinvoker (0.71.4) + - React-Codegen (0.71.4): - FBReactNativeSpec - hermes-engine - RCT-Folly @@ -180,209 +180,209 @@ PODS: - React-jsiexecutor - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - React-Core (0.71.3): + - React-Core (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-Core/Default (= 0.71.3) - - React-cxxreact (= 0.71.3) + - React-Core/Default (= 0.71.4) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/CoreModulesHeaders (0.71.3): + - React-Core/CoreModulesHeaders (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/Default (0.71.3): + - React-Core/Default (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/DevSupport (0.71.3): + - React-Core/DevSupport (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-Core/Default (= 0.71.3) - - React-Core/RCTWebSocket (= 0.71.3) - - React-cxxreact (= 0.71.3) + - React-Core/Default (= 0.71.4) + - React-Core/RCTWebSocket (= 0.71.4) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-jsinspector (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-jsinspector (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/RCTActionSheetHeaders (0.71.3): + - React-Core/RCTActionSheetHeaders (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/RCTAnimationHeaders (0.71.3): + - React-Core/RCTAnimationHeaders (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/RCTBlobHeaders (0.71.3): + - React-Core/RCTBlobHeaders (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/RCTImageHeaders (0.71.3): + - React-Core/RCTImageHeaders (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/RCTLinkingHeaders (0.71.3): + - React-Core/RCTLinkingHeaders (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/RCTNetworkHeaders (0.71.3): + - React-Core/RCTNetworkHeaders (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/RCTSettingsHeaders (0.71.3): + - React-Core/RCTSettingsHeaders (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/RCTTextHeaders (0.71.3): + - React-Core/RCTTextHeaders (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/RCTVibrationHeaders (0.71.3): + - React-Core/RCTVibrationHeaders (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - React-Core/Default - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-Core/RCTWebSocket (0.71.3): + - React-Core/RCTWebSocket (0.71.4): - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-Core/Default (= 0.71.3) - - React-cxxreact (= 0.71.3) + - React-Core/Default (= 0.71.4) + - React-cxxreact (= 0.71.4) - React-hermes - - React-jsi (= 0.71.3) - - React-jsiexecutor (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-jsi (= 0.71.4) + - React-jsiexecutor (= 0.71.4) + - React-perflogger (= 0.71.4) - Yoga - - React-CoreModules (0.71.3): + - React-CoreModules (0.71.4): - RCT-Folly (= 2021.07.22.00) - - RCTTypeSafety (= 0.71.3) - - React-Codegen (= 0.71.3) - - React-Core/CoreModulesHeaders (= 0.71.3) - - React-jsi (= 0.71.3) + - RCTTypeSafety (= 0.71.4) + - React-Codegen (= 0.71.4) + - React-Core/CoreModulesHeaders (= 0.71.4) + - React-jsi (= 0.71.4) - React-RCTBlob - - React-RCTImage (= 0.71.3) - - ReactCommon/turbomodule/core (= 0.71.3) - - React-cxxreact (0.71.3): + - React-RCTImage (= 0.71.4) + - ReactCommon/turbomodule/core (= 0.71.4) + - React-cxxreact (0.71.4): - boost (= 1.76.0) - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-callinvoker (= 0.71.3) - - React-jsi (= 0.71.3) - - React-jsinspector (= 0.71.3) - - React-logger (= 0.71.3) - - React-perflogger (= 0.71.3) - - React-runtimeexecutor (= 0.71.3) - - React-hermes (0.71.3): + - React-callinvoker (= 0.71.4) + - React-jsi (= 0.71.4) + - React-jsinspector (= 0.71.4) + - React-logger (= 0.71.4) + - React-perflogger (= 0.71.4) + - React-runtimeexecutor (= 0.71.4) + - React-hermes (0.71.4): - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - RCT-Folly/Futures (= 2021.07.22.00) - - React-cxxreact (= 0.71.3) + - React-cxxreact (= 0.71.4) - React-jsi - - React-jsiexecutor (= 0.71.3) - - React-jsinspector (= 0.71.3) - - React-perflogger (= 0.71.3) - - React-jsi (0.71.3): + - React-jsiexecutor (= 0.71.4) + - React-jsinspector (= 0.71.4) + - React-perflogger (= 0.71.4) + - React-jsi (0.71.4): - boost (= 1.76.0) - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-jsiexecutor (0.71.3): + - React-jsiexecutor (0.71.4): - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-cxxreact (= 0.71.3) - - React-jsi (= 0.71.3) - - React-perflogger (= 0.71.3) - - React-jsinspector (0.71.3) - - React-logger (0.71.3): + - React-cxxreact (= 0.71.4) + - React-jsi (= 0.71.4) + - React-perflogger (= 0.71.4) + - React-jsinspector (0.71.4) + - React-logger (0.71.4): - glog - react-native-blur (4.3.0): - React-Core @@ -407,92 +407,90 @@ PODS: - React-Core - react-native-version-number (0.3.6): - React - - react-native-webview (11.26.0): - - React-Core - - React-perflogger (0.71.3) - - React-RCTActionSheet (0.71.3): - - React-Core/RCTActionSheetHeaders (= 0.71.3) - - React-RCTAnimation (0.71.3): + - React-perflogger (0.71.4) + - React-RCTActionSheet (0.71.4): + - React-Core/RCTActionSheetHeaders (= 0.71.4) + - React-RCTAnimation (0.71.4): - RCT-Folly (= 2021.07.22.00) - - RCTTypeSafety (= 0.71.3) - - React-Codegen (= 0.71.3) - - React-Core/RCTAnimationHeaders (= 0.71.3) - - React-jsi (= 0.71.3) - - ReactCommon/turbomodule/core (= 0.71.3) - - React-RCTAppDelegate (0.71.3): + - RCTTypeSafety (= 0.71.4) + - React-Codegen (= 0.71.4) + - React-Core/RCTAnimationHeaders (= 0.71.4) + - React-jsi (= 0.71.4) + - ReactCommon/turbomodule/core (= 0.71.4) + - React-RCTAppDelegate (0.71.4): - RCT-Folly - RCTRequired - RCTTypeSafety - React-Core - ReactCommon/turbomodule/core - - React-RCTBlob (0.71.3): + - React-RCTBlob (0.71.4): - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-Codegen (= 0.71.3) - - React-Core/RCTBlobHeaders (= 0.71.3) - - React-Core/RCTWebSocket (= 0.71.3) - - React-jsi (= 0.71.3) - - React-RCTNetwork (= 0.71.3) - - ReactCommon/turbomodule/core (= 0.71.3) - - React-RCTImage (0.71.3): + - React-Codegen (= 0.71.4) + - React-Core/RCTBlobHeaders (= 0.71.4) + - React-Core/RCTWebSocket (= 0.71.4) + - React-jsi (= 0.71.4) + - React-RCTNetwork (= 0.71.4) + - ReactCommon/turbomodule/core (= 0.71.4) + - React-RCTImage (0.71.4): - RCT-Folly (= 2021.07.22.00) - - RCTTypeSafety (= 0.71.3) - - React-Codegen (= 0.71.3) - - React-Core/RCTImageHeaders (= 0.71.3) - - React-jsi (= 0.71.3) - - React-RCTNetwork (= 0.71.3) - - ReactCommon/turbomodule/core (= 0.71.3) - - React-RCTLinking (0.71.3): - - React-Codegen (= 0.71.3) - - React-Core/RCTLinkingHeaders (= 0.71.3) - - React-jsi (= 0.71.3) - - ReactCommon/turbomodule/core (= 0.71.3) - - React-RCTNetwork (0.71.3): + - RCTTypeSafety (= 0.71.4) + - React-Codegen (= 0.71.4) + - React-Core/RCTImageHeaders (= 0.71.4) + - React-jsi (= 0.71.4) + - React-RCTNetwork (= 0.71.4) + - ReactCommon/turbomodule/core (= 0.71.4) + - React-RCTLinking (0.71.4): + - React-Codegen (= 0.71.4) + - React-Core/RCTLinkingHeaders (= 0.71.4) + - React-jsi (= 0.71.4) + - ReactCommon/turbomodule/core (= 0.71.4) + - React-RCTNetwork (0.71.4): - RCT-Folly (= 2021.07.22.00) - - RCTTypeSafety (= 0.71.3) - - React-Codegen (= 0.71.3) - - React-Core/RCTNetworkHeaders (= 0.71.3) - - React-jsi (= 0.71.3) - - ReactCommon/turbomodule/core (= 0.71.3) - - React-RCTSettings (0.71.3): + - RCTTypeSafety (= 0.71.4) + - React-Codegen (= 0.71.4) + - React-Core/RCTNetworkHeaders (= 0.71.4) + - React-jsi (= 0.71.4) + - ReactCommon/turbomodule/core (= 0.71.4) + - React-RCTSettings (0.71.4): - RCT-Folly (= 2021.07.22.00) - - RCTTypeSafety (= 0.71.3) - - React-Codegen (= 0.71.3) - - React-Core/RCTSettingsHeaders (= 0.71.3) - - React-jsi (= 0.71.3) - - ReactCommon/turbomodule/core (= 0.71.3) - - React-RCTText (0.71.3): - - React-Core/RCTTextHeaders (= 0.71.3) - - React-RCTVibration (0.71.3): + - RCTTypeSafety (= 0.71.4) + - React-Codegen (= 0.71.4) + - React-Core/RCTSettingsHeaders (= 0.71.4) + - React-jsi (= 0.71.4) + - ReactCommon/turbomodule/core (= 0.71.4) + - React-RCTText (0.71.4): + - React-Core/RCTTextHeaders (= 0.71.4) + - React-RCTVibration (0.71.4): - RCT-Folly (= 2021.07.22.00) - - React-Codegen (= 0.71.3) - - React-Core/RCTVibrationHeaders (= 0.71.3) - - React-jsi (= 0.71.3) - - ReactCommon/turbomodule/core (= 0.71.3) - - React-runtimeexecutor (0.71.3): - - React-jsi (= 0.71.3) - - ReactCommon/turbomodule/bridging (0.71.3): + - React-Codegen (= 0.71.4) + - React-Core/RCTVibrationHeaders (= 0.71.4) + - React-jsi (= 0.71.4) + - ReactCommon/turbomodule/core (= 0.71.4) + - React-runtimeexecutor (0.71.4): + - React-jsi (= 0.71.4) + - ReactCommon/turbomodule/bridging (0.71.4): - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-callinvoker (= 0.71.3) - - React-Core (= 0.71.3) - - React-cxxreact (= 0.71.3) - - React-jsi (= 0.71.3) - - React-logger (= 0.71.3) - - React-perflogger (= 0.71.3) - - ReactCommon/turbomodule/core (0.71.3): + - React-callinvoker (= 0.71.4) + - React-Core (= 0.71.4) + - React-cxxreact (= 0.71.4) + - React-jsi (= 0.71.4) + - React-logger (= 0.71.4) + - React-perflogger (= 0.71.4) + - ReactCommon/turbomodule/core (0.71.4): - DoubleConversion - glog - hermes-engine - RCT-Folly (= 2021.07.22.00) - - React-callinvoker (= 0.71.3) - - React-Core (= 0.71.3) - - React-cxxreact (= 0.71.3) - - React-jsi (= 0.71.3) - - React-logger (= 0.71.3) - - React-perflogger (= 0.71.3) + - React-callinvoker (= 0.71.4) + - React-Core (= 0.71.4) + - React-cxxreact (= 0.71.4) + - React-jsi (= 0.71.4) + - React-logger (= 0.71.4) + - React-perflogger (= 0.71.4) - rn-fetch-blob (0.12.0): - React-Core - RNBackgroundFetch (4.1.9): @@ -627,7 +625,6 @@ DEPENDENCIES: - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-splash-screen (from `../node_modules/react-native-splash-screen`) - react-native-version-number (from `../node_modules/react-native-version-number`) - - react-native-webview (from `../node_modules/react-native-webview`) - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) @@ -770,8 +767,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-splash-screen" react-native-version-number: :path: "../node_modules/react-native-version-number" - react-native-webview: - :path: "../node_modules/react-native-webview" React-perflogger: :path: "../node_modules/react-native/ReactCommon/reactperflogger" React-RCTActionSheet: @@ -846,38 +841,38 @@ SPEC CHECKSUMS: EXJSONUtils: 48b1e764ac35160e6f54d21ab60d7d9501f3e473 EXManifests: 500666d48e8dd7ca5a482c9e729e4a7a6c34081b EXMediaLibrary: 587cd8aad27a6fc8d7c38b950bc75bc1845a7480 - Expo: 707f9b0039eacc6a1dce90c08c9e37b9c417bba2 + Expo: 863488a600a4565698a79577117c70b170054d08 expo-dev-client: 7c1ef51516853465f4d448c14ddf365167d20361 expo-dev-launcher: 90de99d9e5d1a883d81355ca10e87c2f3c81d46e - expo-dev-menu: d4369e74d8d21a0ccdee35f7c732e7118b0fee16 + expo-dev-menu: 4f54ef98df59d9d625677cb18ad4582de92b4a7d expo-dev-menu-interface: 6c82ae323c4b8724dead4763ce3ff24a2108bdb1 ExpoImagePicker: 270dea232b3a072d981dd564e2cafc63a864edb1 ExpoKeepAwake: 69f5f627670d62318410392d03e0b5db0f85759a ExpoLocalization: f26cd431ad9ea3533c5b08c4fabd879176a794bb - ExpoModulesCore: 397fc99e9d6c9dcc010f36d5802097c17b90424c + ExpoModulesCore: 6e0259511f4c4341b6b8357db393624df2280828 EXSplashScreen: cd7fb052dff5ba8311d5c2455ecbebffe1b7a8ca EXUpdatesInterface: dd699d1930e28639dcbd70a402caea98e86364ca - FBLazyVector: 60195509584153283780abdac5569feffb8f08cc - FBReactNativeSpec: 9c191fb58d06dc05ab5559a5505fc32139e9e4a2 + FBLazyVector: 446e84642979fff0ba57f3c804c2228a473aeac2 + FBReactNativeSpec: 241709e132e3bf1526c1c4f00bc5384dd39dfba9 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b - hermes-engine: 38bfe887e456b33b697187570a08de33969f5db7 + hermes-engine: a1f157c49ea579c28b0296bda8530e980c45bdb3 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 - RCTRequired: bec48f07daf7bcdc2655a0cde84e07d24d2a9e2a - RCTTypeSafety: 171394eebacf71e1cfad79dbfae7ee8fc16ca80a - React: d7433ccb6a8c36e4cbed59a73c0700fc83c3e98a - React-callinvoker: 15f165009bd22ae829b2b600e50bcc98076ce4b8 - React-Codegen: b5910000eaf1e0c2f47d29be6f82f5f1264420d7 - React-Core: b6f2f78d580a90b83fd7b0d1c6911c799f6eac82 - React-CoreModules: e0cbc1a4f4f3f60e23c476fef7ab37be363ea8c1 - React-cxxreact: c87f3f124b2117d00d410b35f16c2257e25e50fa - React-hermes: c64ca6bdf16a7069773103c9bedaf30ec90ab38f - React-jsi: 39729361645568e238081b3b3180fbad803f25a4 - React-jsiexecutor: 515b703d23ffadeac7687bc2d12fb08b90f0aaa1 - React-jsinspector: 9f7c9137605e72ca0343db4cea88006cb94856dd - React-logger: 957e5dc96d9dbffc6e0f15e0ee4d2b42829ff207 + RCTRequired: 5a024fdf458fa8c0d82fc262e76f982d4dcdecdd + RCTTypeSafety: b6c253064466411c6810b45f66bc1e43ce0c54ba + React: 715292db5bd46989419445a5547954b25d2090f0 + React-callinvoker: 105392d1179058585b564d35b4592fe1c46d6fba + React-Codegen: b75333b93d835afce84b73472927cccaef2c9f8c + React-Core: 88838ed1724c64905fc6c0811d752828a92e395b + React-CoreModules: cd238b4bb8dc8529ccc8b34ceae7267b04ce1882 + React-cxxreact: 291bfab79d8098dc5ebab98f62e6bdfe81b3955a + React-hermes: b1e67e9a81c71745704950516f40ee804349641c + React-jsi: c9d5b563a6af6bb57034a82c2b0d39d0a7483bdc + React-jsiexecutor: d6b7fa9260aa3cb40afee0507e3bc1d17ecaa6f2 + React-jsinspector: 1f51e775819199d3fe9410e69ee8d4c4161c7b06 + React-logger: 0d58569ec51d30d1792c5e86a8e3b78d24b582c6 react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3 react-native-cameraroll: f3050460fe1708378698c16686bfaa5f34099be2 react-native-get-random-values: a6ea6a8a65dc93e96e24a11105b1a9c8cfe1d72a @@ -887,20 +882,19 @@ SPEC CHECKSUMS: react-native-safe-area-context: 39c2d8be3328df5d437ac1700f4f3a4f75716acc react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457 react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f - react-native-webview: 994b9f8fbb504d6314dc40d83f94f27c6831b3bf - React-perflogger: af8a3d31546077f42d729b949925cc4549f14def - React-RCTActionSheet: 57cc5adfefbaaf0aae2cf7e10bccd746f2903673 - React-RCTAnimation: 11c61e94da700c4dc915cf134513764d87fc5e2b - React-RCTAppDelegate: c3980adeaadcfd6cb495532e928b36ac6db3c14a - React-RCTBlob: ccc5049d742b41971141415ca86b83b201495695 - React-RCTImage: 7a9226b0944f1e76e8e01e35a9245c2477cdbabb - React-RCTLinking: bbe8cc582046a9c04f79c235b73c93700263e8b4 - React-RCTNetwork: fc2ca322159dc54e06508d4f5c3e934da63dc013 - React-RCTSettings: f1e9db2cdf946426d3f2b210e4ff4ce0f0d842ef - React-RCTText: 1c41dd57e5d742b1396b4eeb251851ce7ff0fca1 - React-RCTVibration: 5199a180d04873366a83855de55ac33ce60fe4d5 - React-runtimeexecutor: 7bf0dafc7b727d93c8cb94eb00a9d3753c446c3e - ReactCommon: 6f65ea5b7d84deb9e386f670dd11ce499ded7b40 + React-perflogger: 0bb0522a12e058f6eb69d888bc16f40c16c4b907 + React-RCTActionSheet: bfd675a10f06a18728ea15d82082d48f228a213a + React-RCTAnimation: 2fa220b2052ec75b733112aca39143d34546a941 + React-RCTAppDelegate: 8564f93c1d9274e95e3b0c746d08a87ff5a621b2 + React-RCTBlob: d0336111f46301ae8aba2e161817e451aad72dd6 + React-RCTImage: fec592c46edb7c12a9cde08780bdb4a688416c62 + React-RCTLinking: 14eccac5d2a3b34b89dbfa29e8ef6219a153fe2d + React-RCTNetwork: 1fbce92e772e39ca3687a2ebb854501ff6226dd7 + React-RCTSettings: 1abea36c9bb16d9979df6c4b42e2ea281b4bbcc5 + React-RCTText: 15355c41561a9f43dfd23616d0a0dd40ba05ed61 + React-RCTVibration: ad17efcfb2fa8f6bfd8ac0cf48d96668b8b28e0b + React-runtimeexecutor: 8fa50b38df6b992c76537993a2b0553d3b088004 + ReactCommon: b49a4b00ca6d181ff74b17c12b2d59ac4add0bde rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba RNBackgroundFetch: 642777e4e76435773c149d565a043d66f1781237 RNCAsyncStorage: 09fc8595e6d6f6d5abf16b23a56b257d9c6b7c5b @@ -921,7 +915,7 @@ SPEC CHECKSUMS: sovran-react-native: fd3dc8f1a4b14acdc4ad25fc6b4ac4f52a2a2a15 Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863 - Yoga: 5ed1699acbba8863755998a4245daa200ff3817b + Yoga: 79dd7410de6f8ad73a77c868d3d368843f0c93e0 PODFILE CHECKSUM: 5570c7b7d6ce7895f95d9db8a3a99b136a3f42c4 diff --git a/ios/bluesky/Info.plist b/ios/bluesky/Info.plist index 117d763e..f58ed1b5 100644 --- a/ios/bluesky/Info.plist +++ b/ios/bluesky/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.10 + 1.11 CFBundleSignature ???? CFBundleURLTypes diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 32f3bc9b..1e87df81 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -1,86 +1,73 @@ import {AddressInfo} from 'net' import os from 'os' +import net from 'net' import path from 'path' import * as crypto from '@atproto/crypto' -import PDSServer, { - Database as PDSDatabase, - MemoryBlobStore, - ServerConfig as PDSServerConfig, -} from '@atproto/pds' -import * as plc from '@atproto/plc' -import AtpAgent from '@atproto/api' +import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds' +import * as plc from '@did-plc/lib' +import {PlcServer, Database as PlcDatabase} from '@did-plc/server' +import {BskyAgent} from '@atproto/api' + +const ADMIN_PASSWORD = 'admin-pass' +const SECOND = 1000 +const MINUTE = SECOND * 60 +const HOUR = MINUTE * 60 export interface TestUser { email: string did: string - declarationCid: string handle: string password: string - agent: AtpAgent -} - -export interface TestUsers { - alice: TestUser - bob: TestUser - carla: TestUser + agent: BskyAgent } export interface TestPDS { pdsUrl: string - users: TestUsers + mocker: Mocker close: () => Promise } -// 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 { - const keypair = await crypto.EcdsaKeypair.create() + const repoSigningKey = await crypto.Secp256k1Keypair.create() + const plcRotationKey = await crypto.Secp256k1Keypair.create() + const port = await getPort() - // run plc server - const plcDb = plc.Database.memory() - await plcDb.migrateToLatestOrThrow() - const plcServer = plc.PlcServer.create({db: plcDb}) + const plcDb = PlcDatabase.mock() + + const plcServer = PlcServer.create({db: plcDb}) const plcListener = await plcServer.start() const plcPort = (plcListener.address() as AddressInfo).port const plcUrl = `http://localhost:${plcPort}` - const recoveryKey = (await crypto.EcdsaKeypair.create()).did() + const recoveryKey = (await crypto.Secp256k1Keypair.create()).did() - const plcClient = new plc.PlcClient(plcUrl) - const serverDid = await plcClient.createDid( - keypair, - recoveryKey, - 'localhost', - 'https://pds.public.url', - ) + const plcClient = new plc.Client(plcUrl) + const serverDid = await plcClient.createDid({ + signingKey: repoSigningKey.did(), + rotationKeys: [recoveryKey, plcRotationKey.did()], + handle: 'localhost', + pds: `http://localhost:${port}`, + signer: plcRotationKey, + }) const blobstoreLoc = path.join(os.tmpdir(), crypto.randomStr(5, 'base32')) - const cfg = new PDSServerConfig({ + const cfg = new ServerConfig({ debugMode: true, version: '0.0.0', scheme: 'http', hostname: 'localhost', + port, serverDid, recoveryKey, - adminPassword: 'admin-pass', + adminPassword: ADMIN_PASSWORD, inviteRequired: false, didPlcUrl: plcUrl, jwtSecret: 'jwt-secret', availableUserDomains: ['.test'], appUrlPasswordReset: 'app://forgot-password', emailNoReplyAddress: 'noreply@blueskyweb.xyz', - publicUrl: 'https://pds.public.url', + publicUrl: `http://localhost:${port}`, imgUriSalt: '9dd04221f5755bce5f55f47464c27e1e', imgUriKey: 'f23ecd142835025f42c3db2cf25dd813956c178392760256211f9d315f8ab4d8', @@ -88,22 +75,33 @@ export async function createServer(): Promise { blobstoreLocation: `${blobstoreLoc}/blobs`, blobstoreTmp: `${blobstoreLoc}/tmp`, maxSubscriptionBuffer: 200, - repoBackfillLimitMs: 1e3 * 60 * 60, + repoBackfillLimitMs: HOUR, }) - const db = PDSDatabase.memory() + const db = + cfg.dbPostgresUrl !== undefined + ? Database.postgres({ + url: cfg.dbPostgresUrl, + schema: cfg.dbPostgresSchema, + }) + : Database.memory() await db.migrateToLatestOrThrow() + const blobstore = new MemoryBlobStore() - const pds = PDSServer.create({db, blobstore, keypair, config: cfg}) - const pdsServer = await pds.start() - const pdsPort = (pdsServer.address() as AddressInfo).port - const pdsUrl = `http://localhost:${pdsPort}` - const testUsers = await genMockData(pdsUrl) + const pds = PDS.create({ + db, + blobstore, + repoSigningKey, + plcRotationKey, + config: cfg, + }) + await pds.start() + const pdsUrl = `http://localhost:${port}` return { pdsUrl, - users: testUsers, + mocker: new Mocker(pdsUrl), async close() { await pds.destroy() await plcServer.destroy() @@ -111,90 +109,93 @@ export async function createServer(): Promise { } } -async function genMockData(pdsUrl: string): Promise { - const date = dateGen() +class Mocker { + agent: BskyAgent + users: Record = {} - const agents = { - loggedout: new AtpAgent({service: pdsUrl}), - alice: new AtpAgent({service: pdsUrl}), - bob: new AtpAgent({service: pdsUrl}), - carla: new AtpAgent({service: pdsUrl}), + constructor(public service: string) { + this.agent = new BskyAgent({service}) } - const users: TestUser[] = [ - { - email: 'alice@test.com', - did: '', - declarationCid: '', - handle: 'alice.test', - password: 'hunter2', - agent: agents.alice, - }, - { - email: 'bob@test.com', - did: '', - declarationCid: '', - handle: 'bob.test', - password: 'hunter2', - agent: agents.bob, - }, - { - email: 'carla@test.com', - did: '', - declarationCid: '', - handle: 'carla.test', - password: 'hunter2', - agent: agents.carla, - }, - ] - const alice = users[0] - const bob = users[1] - const carla = users[2] - let _i = 1 - for (const user of users) { - const res = await agents.loggedout.api.com.atproto.account.create({ - email: user.email, - handle: user.handle, - password: user.password, + // NOTE + // deterministic date generator + // we use this to ensure the mock dataset is always the same + // which is very useful when testing + *dateGen() { + let start = 1657846031914 + while (true) { + yield new Date(start).toISOString() + start += 1e3 + } + } + + async createUser(name: string) { + const agent = new BskyAgent({service: this.agent.service}) + const email = `fake${Object.keys(this.users).length + 1}@fake.com` + const res = await agent.createAccount({ + email, + handle: name + '.test', + password: 'hunter2', }) - user.agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`) - const {data: profile} = await user.agent.api.app.bsky.actor.getProfile({ - actor: user.handle, - }) - user.did = res.data.did - user.declarationCid = profile.declaration.cid - await user.agent.api.app.bsky.actor.profile.create( - {did: user.did}, - { - displayName: ucfirst(user.handle).slice(0, -5), - description: `Test user ${_i++}`, - }, - ) + this.users[name] = { + did: res.data.did, + email, + handle: name + '.test', + password: 'hunter2', + agent: agent, + } } - // everybody follows everybody - const follow = async (author: TestUser, subject: TestUser) => { - await author.agent.api.app.bsky.graph.follow.create( - {did: author.did}, - { - subject: { - did: subject.did, - declarationCid: subject.declarationCid, - }, - createdAt: date.next().value || '', - }, - ) + async follow(a: string, b: string) { + await this.users[a].agent.follow(this.users[b].did) } - await follow(alice, bob) - await follow(alice, carla) - await follow(bob, alice) - await follow(bob, carla) - await follow(carla, alice) - await follow(carla, bob) - return {alice, bob, carla} + async generateStandardGraph() { + await this.createUser('alice') + await this.createUser('bob') + await this.createUser('carla') + + await this.users.alice.agent.upsertProfile(() => ({ + displayName: 'Alice', + description: 'Test user 1', + })) + + await this.users.bob.agent.upsertProfile(() => ({ + displayName: 'Bob', + description: 'Test user 2', + })) + + await this.users.carla.agent.upsertProfile(() => ({ + displayName: 'Carla', + description: 'Test user 3', + })) + + await this.follow('alice', 'bob') + await this.follow('alice', 'carla') + await this.follow('bob', 'alice') + await this.follow('bob', 'carla') + await this.follow('carla', 'alice') + await this.follow('carla', 'bob') + } } -function ucfirst(str: string): string { - return str.at(0)?.toUpperCase() + str.slice(1) +const checkAvailablePort = (port: number) => + new Promise(resolve => { + const server = net.createServer() + server.unref() + server.on('error', () => resolve(false)) + server.listen({port}, () => { + server.close(() => { + resolve(true) + }) + }) + }) + +async function getPort() { + for (let i = 3000; i < 65000; i++) { + if (await checkAvailablePort(i)) { + return i + } + } + throw new Error('Unable to find an available port') } diff --git a/metro.config.js b/metro.config.js index 9e8e8745..b1714479 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,3 +1,7 @@ // Learn more https://docs.expo.io/guides/customizing-metro const {getDefaultConfig} = require('expo/metro-config') -module.exports = getDefaultConfig(__dirname) +const cfg = getDefaultConfig(__dirname) +cfg.resolver.sourceExts = process.env.RN_SRC_EXT + ? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts) + : cfg.resolver.sourceExts +module.exports = cfg diff --git a/package.json b/package.json index 8f2f7e9d..2ac5367a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.10.0", + "version": "1.11.0", "private": true, "scripts": { "postinstall": "patch-package", @@ -15,12 +15,13 @@ "test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit", "test-coverage": "jest --coverage", "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx", - "e2e": "detox test --configuration ios.sim.debug --take-screenshots all" + "e2e:mock-server": "ts-node __e2e__/mock-server.ts", + "e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", + "e2e:build": "detox build -c ios.sim.debug", + "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "0.1.3", - "@atproto/lexicon": "^0.0.4", - "@atproto/xrpc": "^0.0.4", + "@atproto/api": "0.2.0", "@bam.tech/react-native-image-resizer": "^3.0.4", "@expo/webpack-config": "^18.0.1", "@fortawesome/fontawesome-svg-core": "^6.1.1", @@ -55,7 +56,7 @@ "await-lock": "^2.2.2", "base64-js": "^1.5.1", "email-validator": "^2.0.4", - "expo": "~48.0.0-beta.2", + "expo": "~48.0.9", "expo-camera": "~13.2.1", "expo-dev-client": "~2.1.1", "expo-image-picker": "~14.1.1", @@ -63,6 +64,8 @@ "expo-media-library": "~15.2.3", "expo-splash-screen": "~0.18.1", "expo-status-bar": "~1.4.4", + "fast-text-encoding": "^1.0.6", + "graphemer": "^1.4.0", "he": "^1.2.0", "history": "^5.3.0", "js-sha256": "^0.9.0", @@ -84,7 +87,7 @@ "react-avatar-editor": "^13.0.0", "react-circular-progressbar": "^2.1.0", "react-dom": "^18.2.0", - "react-native": "0.71.3", + "react-native": "0.71.4", "react-native-appstate-hook": "^1.0.6", "react-native-background-fetch": "^4.1.8", "react-native-drawer-layout": "^3.2.0", @@ -109,19 +112,17 @@ "react-native-version-number": "^0.3.6", "react-native-web": "^0.18.11", "react-native-web-linear-gradient": "^1.1.2", - "react-native-web-webview": "^1.0.2", - "react-native-webview": "11.26.0", - "react-native-youtube-iframe": "^2.2.2", "rn-fetch-blob": "^0.12.0", "tippy.js": "^6.3.7", "tlds": "^1.234.0", "zod": "^3.20.2" }, "devDependencies": { - "@atproto/pds": "^0.0.3", + "@atproto/pds": "^0.1.0", "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", + "@did-plc/server": "^0.0.1", "@react-native-community/eslint-config": "^3.0.0", "@testing-library/jest-native": "^5.4.1", "@testing-library/react-native": "^11.5.2", @@ -150,13 +151,14 @@ "eslint-plugin-ft-flow": "^2.0.3", "html-webpack-plugin": "^5.5.0", "jest": "^29.4.3", - "jest-expo": "^48.0.0-beta.2", + "jest-expo": "^48.0.2", "jest-junit": "^15.0.0", "metro-react-native-babel-preset": "^0.73.7", "prettier": "^2.8.3", "react-native-dotenv": "^3.3.1", "react-scripts": "^5.0.1", "react-test-renderer": "18.2.0", + "ts-node": "^10.9.1", "typescript": "^4.4.4", "url-loader": "^4.1.1", "webpack": "^5.75.0", diff --git a/src/App.native.tsx b/src/App.native.tsx index ebe6a7cd..0adbae60 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -29,7 +29,6 @@ const App = observer(() => { analytics.init(store) notifee.init(store) SplashScreen.hide() - store.hackCheckIfUpgradeNeeded() Linking.getInitialURL().then((url: string | null) => { if (url) { handleLink(url) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 2bfc84ea..a1dbc4af 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -31,7 +31,7 @@ import {ProfileScreen} from './view/screens/Profile' import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowsScreen} from './view/screens/ProfileFollows' import {PostThreadScreen} from './view/screens/PostThread' -import {PostUpvotedByScreen} from './view/screens/PostUpvotedBy' +import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostRepostedByScreen} from './view/screens/PostRepostedBy' import {DebugScreen} from './view/screens/Debug' import {LogScreen} from './view/screens/Log' @@ -62,7 +62,7 @@ function commonScreens(Stack: typeof HomeTab) { /> - + diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts index b7be6913..7c38625a 100644 --- a/src/lib/api/api-polyfill.ts +++ b/src/lib/api/api-polyfill.ts @@ -1,11 +1,11 @@ -import AtpAgent from '@atproto/api' +import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api' import RNFS from 'react-native-fs' const GET_TIMEOUT = 15e3 // 15s const POST_TIMEOUT = 60e3 // 60s export function doPolyfill() { - AtpAgent.configure({fetch: fetchHandler}) + BskyAgent.configure({fetch: fetchHandler}) } interface FetchHandlerResponse { @@ -22,7 +22,7 @@ async function fetchHandler( ): Promise { const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type'] if (reqMimeType && reqMimeType.startsWith('application/json')) { - reqBody = JSON.stringify(reqBody) + reqBody = stringifyLex(reqBody) } else if ( typeof reqBody === 'string' && (reqBody.startsWith('/') || reqBody.startsWith('file:')) @@ -65,7 +65,7 @@ async function fetchHandler( let resBody if (resMimeType) { if (resMimeType.startsWith('application/json')) { - resBody = await res.json() + resBody = jsonToLex(await res.json()) } else if (resMimeType.startsWith('text/')) { resBody = await res.text() } else { diff --git a/src/lib/api/api-polyfill.web.ts b/src/lib/api/api-polyfill.web.ts index 1469cf90..1ad22b3d 100644 --- a/src/lib/api/api-polyfill.web.ts +++ b/src/lib/api/api-polyfill.web.ts @@ -1,4 +1,3 @@ export function doPolyfill() { - // TODO needed? native fetch may work fine -prf - // AtpApi.xrpc.fetch = fetchHandler + // no polyfill is needed on web } diff --git a/src/lib/api/build-suggested-posts.ts b/src/lib/api/build-suggested-posts.ts index defa4531..b9feefc7 100644 --- a/src/lib/api/build-suggested-posts.ts +++ b/src/lib/api/build-suggested-posts.ts @@ -1,9 +1,9 @@ import {RootStoreModel} from 'state/index' import { - AppBskyFeedFeedViewPost, + AppBskyFeedDefs, AppBskyFeedGetAuthorFeed as GetAuthorFeed, } from '@atproto/api' -type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost +type ReasonRepost = AppBskyFeedDefs.ReasonRepost async function getMultipleAuthorsPosts( rootStore: RootStoreModel, @@ -12,12 +12,12 @@ async function getMultipleAuthorsPosts( limit: number = 10, ) { const responses = await Promise.all( - authors.map((author, index) => - rootStore.api.app.bsky.feed + authors.map((actor, index) => + rootStore.agent .getAuthorFeed({ - author, + actor, limit, - before: cursor ? cursor.split(',')[index] : undefined, + cursor: cursor ? cursor.split(',')[index] : undefined, }) .catch(_err => ({success: false, headers: {}, data: {feed: []}})), ), @@ -29,14 +29,14 @@ function mergePosts( responses: GetAuthorFeed.Response[], {repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean}, ) { - let posts: AppBskyFeedFeedViewPost.Main[] = [] + let posts: AppBskyFeedDefs.FeedViewPost[] = [] if (bestOfOnly) { for (const res of responses) { if (res.success) { - // filter the feed down to the post with the most upvotes + // filter the feed down to the post with the most likes res.data.feed = res.data.feed.reduce( - (acc: AppBskyFeedFeedViewPost.Main[], v) => { + (acc: AppBskyFeedDefs.FeedViewPost[], v) => { if ( !acc?.[0] && !v.reason && @@ -49,7 +49,7 @@ function mergePosts( acc && !v.reason && !v.reply && - v.post.upvoteCount > acc[0]?.post.upvoteCount && + (v.post.likeCount || 0) > (acc[0]?.post.likeCount || 0) && isRecentEnough(v.post.indexedAt) ) { return [v] @@ -92,7 +92,7 @@ function mergePosts( return posts } -function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean { +function isARepostOfSomeoneElse(post: AppBskyFeedDefs.FeedViewPost): boolean { return ( post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' && post.post.author.did !== (post.reason as ReasonRepost).by.did diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index e9a32b7a..6fdc9a48 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -1,8 +1,8 @@ -import {AppBskyFeedFeedViewPost} from '@atproto/api' +import {AppBskyFeedDefs} from '@atproto/api' import lande from 'lande' -type FeedViewPost = AppBskyFeedFeedViewPost.Main -import {hasProp} from '@atproto/lexicon' +import {hasProp} from 'lib/type-guards' import {LANGUAGES_MAP_CODE2} from '../../locale/languages' +type FeedViewPost = AppBskyFeedDefs.FeedViewPost export type FeedTunerFn = ( tuner: FeedTuner, @@ -174,7 +174,7 @@ export class FeedTuner { } const item = slices[i].rootItem const isRepost = Boolean(item.reason) - if (!isRepost && item.post.upvoteCount < 2) { + if (!isRepost && (item.post.likeCount || 0) < 2) { slices.splice(i, 1) } } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 85eca4a6..a5aa916d 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,16 +1,16 @@ import { AppBskyEmbedImages, AppBskyEmbedExternal, - ComAtprotoBlobUpload, AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + ComAtprotoRepoUploadBlob, + RichText, } from '@atproto/api' import {AtUri} from '../../third-party/uri' import {RootStoreModel} from 'state/models/root-store' -import {extractEntities} from 'lib/strings/rich-text-detection' import {isNetworkError} from 'lib/strings/errors' import {LinkMeta} from '../link-meta/link-meta' import {Image} from '../media/manip' -import {RichText} from '../strings/rich-text' import {isWeb} from 'platform/detection' export interface ExternalEmbedDraft { @@ -27,7 +27,7 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) { if (didOrHandle.startsWith('did:')) { return didOrHandle } - const res = await store.api.com.atproto.handle.resolve({ + const res = await store.agent.resolveHandle({ handle: didOrHandle, }) return res.data.did @@ -37,15 +37,15 @@ export async function uploadBlob( store: RootStoreModel, blob: string, encoding: string, -): Promise { +): Promise { if (isWeb) { // `blob` should be a data uri - return store.api.com.atproto.blob.upload(convertDataURIToUint8Array(blob), { + return store.agent.uploadBlob(convertDataURIToUint8Array(blob), { encoding, }) } else { // `blob` should be a path to a file in the local FS - return store.api.com.atproto.blob.upload( + return store.agent.uploadBlob( blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts {encoding}, ) @@ -70,22 +70,18 @@ export async function post(store: RootStoreModel, opts: PostOpts) { | AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | AppBskyEmbedRecord.Main + | AppBskyEmbedRecordWithMedia.Main | undefined let reply - const text = new RichText(opts.rawText, undefined, { - cleanNewlines: true, - }).text.trim() + const rt = new RichText( + {text: opts.rawText.trim()}, + { + cleanNewlines: true, + }, + ) opts.onStateChange?.('Processing...') - const entities = extractEntities(text, opts.knownHandles) - if (entities) { - for (const ent of entities) { - if (ent.type === 'mention') { - const prof = await store.profiles.getProfile(ent.value) - ent.value = prof.data.did - } - } - } + await rt.detectFacets(store.agent) if (opts.quote) { embed = { @@ -95,24 +91,37 @@ export async function post(store: RootStoreModel, opts: PostOpts) { cid: opts.quote.cid, }, } as AppBskyEmbedRecord.Main - } else if (opts.images?.length) { - embed = { - $type: 'app.bsky.embed.images', - images: [], - } as AppBskyEmbedImages.Main - let i = 1 + } + + if (opts.images?.length) { + const images: AppBskyEmbedImages.Image[] = [] for (const image of opts.images) { - opts.onStateChange?.(`Uploading image #${i++}...`) + opts.onStateChange?.(`Uploading image #${images.length + 1}...`) const res = await uploadBlob(store, image, 'image/jpeg') - embed.images.push({ - image: { - cid: res.data.cid, - mimeType: 'image/jpeg', - }, + images.push({ + image: res.data.blob, alt: '', // TODO supply alt text }) } - } else if (opts.extLink) { + + if (opts.quote) { + embed = { + $type: 'app.bsky.embed.recordWithMedia', + record: embed, + media: { + $type: 'app.bsky.embed.images', + images, + }, + } as AppBskyEmbedRecordWithMedia.Main + } else { + embed = { + $type: 'app.bsky.embed.images', + images, + } as AppBskyEmbedImages.Main + } + } + + if (opts.extLink && !opts.images?.length) { let thumb if (opts.extLink.localThumb) { opts.onStateChange?.('Uploading link thumbnail...') @@ -138,27 +147,41 @@ export async function post(store: RootStoreModel, opts: PostOpts) { opts.extLink.localThumb.path, encoding, ) - thumb = { - cid: thumbUploadRes.data.cid, - mimeType: encoding, - } + thumb = thumbUploadRes.data.blob } } - embed = { - $type: 'app.bsky.embed.external', - external: { - uri: opts.extLink.uri, - title: opts.extLink.meta?.title || '', - description: opts.extLink.meta?.description || '', - thumb, - }, - } as AppBskyEmbedExternal.Main + + if (opts.quote) { + embed = { + $type: 'app.bsky.embed.recordWithMedia', + record: embed, + media: { + $type: 'app.bsky.embed.external', + external: { + uri: opts.extLink.uri, + title: opts.extLink.meta?.title || '', + description: opts.extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main, + } as AppBskyEmbedRecordWithMedia.Main + } else { + embed = { + $type: 'app.bsky.embed.external', + external: { + uri: opts.extLink.uri, + title: opts.extLink.meta?.title || '', + description: opts.extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main + } } if (opts.replyTo) { const replyToUrip = new AtUri(opts.replyTo) - const parentPost = await store.api.app.bsky.feed.post.get({ - user: replyToUrip.host, + const parentPost = await store.agent.getPost({ + repo: replyToUrip.host, rkey: replyToUrip.rkey, }) if (parentPost) { @@ -175,16 +198,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) { try { opts.onStateChange?.('Posting...') - return await store.api.app.bsky.feed.post.create( - {did: store.me.did || ''}, - { - text, - reply, - embed, - entities, - createdAt: new Date().toISOString(), - }, - ) + return await store.agent.post({ + text: rt.text, + facets: rt.facets, + reply, + embed, + }) } catch (e: any) { console.error(`Failed to create post: ${e.toString()}`) if (isNetworkError(e)) { @@ -197,49 +216,6 @@ export async function post(store: RootStoreModel, opts: PostOpts) { } } -export async function repost(store: RootStoreModel, uri: string, cid: string) { - return await store.api.app.bsky.feed.repost.create( - {did: store.me.did || ''}, - { - subject: {uri, cid}, - createdAt: new Date().toISOString(), - }, - ) -} - -export async function unrepost(store: RootStoreModel, repostUri: string) { - const repostUrip = new AtUri(repostUri) - return await store.api.app.bsky.feed.repost.delete({ - did: repostUrip.hostname, - rkey: repostUrip.rkey, - }) -} - -export async function follow( - store: RootStoreModel, - subjectDid: string, - subjectDeclarationCid: string, -) { - return await store.api.app.bsky.graph.follow.create( - {did: store.me.did || ''}, - { - subject: { - did: subjectDid, - declarationCid: subjectDeclarationCid, - }, - createdAt: new Date().toISOString(), - }, - ) -} - -export async function unfollow(store: RootStoreModel, followUri: string) { - const followUrip = new AtUri(followUri) - return await store.api.app.bsky.graph.follow.delete({ - did: followUrip.hostname, - rkey: followUrip.rkey, - }) -} - // helpers // = diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx new file mode 100644 index 00000000..9f4765ac --- /dev/null +++ b/src/lib/media/picker.e2e.tsx @@ -0,0 +1,116 @@ +import {RootStoreModel} from 'state/index' +import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' +import { + scaleDownDimensions, + Dim, + compressIfNeeded, + moveToPremanantPath, +} from 'lib/media/manip' +export type {PickedMedia} from './types' +import RNFS from 'react-native-fs' + +let _imageCounter = 0 +async function getFile() { + const files = await RNFS.readDir( + RNFS.LibraryDirectoryPath.split('/') + .slice(0, -5) + .concat(['Media', 'DCIM', '100APPLE']) + .join('/'), + ) + return files[_imageCounter++ % files.length] +} + +export async function openPicker( + _store: RootStoreModel, + opts: PickerOpts, +): Promise { + 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 { + 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 { + const mediaType = opts.mediaType || 'photo' + const item = await getFile() + return { + mediaType, + path: item.path, + mime: 'image/jpeg', + size: item.size, + width: 4288, + height: 2848, + } +} + +export async function pickImagesFlow( + store: RootStoreModel, + maxFiles: number, + maxDim: Dim, + maxSize: number, +) { + const items = await openPicker(store, { + multiple: true, + maxFiles, + mediaType: 'photo', + }) + const result = [] + for (const image of items) { + result.push( + await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), + ) + } + return result +} + +export async function cropAndCompressFlow( + store: RootStoreModel, + path: string, + imgDim: Dim, + maxDim: Dim, + maxSize: number, +) { + // choose target dimensions based on the original + // this causes the photo cropper to start with the full image "selected" + const {width, height} = scaleDownDimensions(imgDim, maxDim) + const cropperRes = await openCropper(store, { + mediaType: 'photo', + path, + freeStyleCropEnabled: true, + width, + height, + }) + + const img = await compressIfNeeded(cropperRes, maxSize) + const permanentPath = await moveToPremanantPath(img.path) + return permanentPath +} diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts index 4baf6405..4b53ed72 100644 --- a/src/lib/notifee.ts +++ b/src/lib/notifee.ts @@ -45,7 +45,7 @@ export function displayNotificationFromModel( let author = notif.author.displayName || notif.author.handle let title: string let body: string = '' - if (notif.isUpvote) { + if (notif.isLike) { title = `${author} liked your post` body = notif.additionalPost?.thread?.postRecord?.text || '' } else if (notif.isRepost) { @@ -65,7 +65,7 @@ export function displayNotificationFromModel( } let image if ( - AppBskyEmbedImages.isPresented(notif.additionalPost?.thread?.post.embed) && + AppBskyEmbedImages.isView(notif.additionalPost?.thread?.post.embed) && notif.additionalPost?.thread?.post.embed.images[0]?.thumb ) { image = notif.additionalPost.thread.post.embed.images[0].thumb diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index cc48e2db..59d94efa 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -10,7 +10,7 @@ export type CommonNavigatorParams = { ProfileFollowers: {name: string} ProfileFollows: {name: string} PostThread: {name: string; rkey: string} - PostUpvotedBy: {name: string; rkey: string} + PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} Debug: undefined Log: undefined diff --git a/src/lib/strings/rich-text-detection.ts b/src/lib/strings/rich-text-detection.ts index 386ed48e..51d09ec5 100644 --- a/src/lib/strings/rich-text-detection.ts +++ b/src/lib/strings/rich-text-detection.ts @@ -1,64 +1,5 @@ -import {AppBskyFeedPost} from '@atproto/api' -type Entity = AppBskyFeedPost.Entity import {isValidDomain} from './url-helpers' -export function extractEntities( - text: string, - knownHandles?: Set, -): 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]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim - while ((match = re.exec(text))) { - let value = match[2] - if (!value.startsWith('http')) { - const domain = match.groups?.domain - if (!domain || !isValidDomain(domain)) { - continue - } - value = `https://${value}` - } - const start = text.indexOf(match[2], match.index) - const index = {start, end: start + match[2].length} - // strip ending puncuation - if (/[.,;!?]$/.test(value)) { - value = value.slice(0, -1) - index.end-- - } - if (/[)]$/.test(value) && !value.includes('(')) { - value = value.slice(0, -1) - index.end-- - } - ents.push({ - type: 'link', - value, - index, - }) - } - } - return ents.length > 0 ? ents : undefined -} - interface DetectedLink { link: string } diff --git a/src/lib/strings/rich-text-sanitize.ts b/src/lib/strings/rich-text-sanitize.ts deleted file mode 100644 index 0b589570..00000000 --- a/src/lib/strings/rich-text-sanitize.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {RichText} from './rich-text' - -const EXCESS_SPACE_RE = /[\r\n]([\u00AD\u2060\u200D\u200C\u200B\s]*[\r\n]){2,}/ -const REPLACEMENT_STR = '\n\n' - -export function removeExcessNewlines(richText: RichText): RichText { - return clean(richText, EXCESS_SPACE_RE, REPLACEMENT_STR) -} - -// TODO: check on whether this works correctly with multi-byte codepoints -export function clean( - richText: RichText, - targetRegexp: RegExp, - replacementString: string, -): RichText { - richText = richText.clone() - - let match = richText.text.match(targetRegexp) - while (match && typeof match.index !== 'undefined') { - const oldText = richText.text - const removeStartIndex = match.index - const removeEndIndex = removeStartIndex + match[0].length - richText.delete(removeStartIndex, removeEndIndex) - if (richText.text === oldText) { - break // sanity check - } - richText.insert(removeStartIndex, replacementString) - match = richText.text.match(targetRegexp) - } - - return richText -} diff --git a/src/lib/strings/rich-text.ts b/src/lib/strings/rich-text.ts deleted file mode 100644 index 1df2144e..00000000 --- a/src/lib/strings/rich-text.ts +++ /dev/null @@ -1,216 +0,0 @@ -/* -= Rich Text Manipulation - -When we sanitize rich text, we have to update the entity indices as the -text is modified. This can be modeled as inserts() and deletes() of the -rich text string. The possible scenarios are outlined below, along with -their expected behaviors. - -NOTE: Slices are start inclusive, end exclusive - -== richTextInsert() - -Target string: - - 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l o w o r l d // string value - ^-------^ // target slice {start: 2, end: 7} - -Scenarios: - -A: ^ // insert "test" at 0 -B: ^ // insert "test" at 4 -C: ^ // insert "test" at 8 - -A = before -> move both by num added -B = inner -> move end by num added -C = after -> noop - -Results: - -A: 0 1 2 3 4 5 6 7 8 910 // string indices - t e s t h e l l o w // string value - ^-------^ // target slice {start: 6, end: 11} - -B: 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l t e s t o w // string value - ^---------------^ // target slice {start: 2, end: 11} - -C: 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l o w o t e s // string value - ^-------^ // target slice {start: 2, end: 7} - -== richTextDelete() - -Target string: - - 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l o w o r l d // string value - ^-------^ // target slice {start: 2, end: 7} - -Scenarios: - -A: ^---------------^ // remove slice {start: 0, end: 9} -B: ^-----^ // remove slice {start: 7, end: 11} -C: ^-----------^ // remove slice {start: 4, end: 11} -D: ^-^ // remove slice {start: 3, end: 5} -E: ^-----^ // remove slice {start: 1, end: 5} -F: ^-^ // remove slice {start: 0, end: 2} - -A = entirely outer -> delete slice -B = entirely after -> noop -C = partially after -> move end to remove-start -D = entirely inner -> move end by num removed -E = partially before -> move start to remove-start index, move end by num removed -F = entirely before -> move both by num removed - -Results: - -A: 0 1 2 3 4 5 6 7 8 910 // string indices - l d // string value - // target slice (deleted) - -B: 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l o w // string value - ^-------^ // target slice {start: 2, end: 7} - -C: 0 1 2 3 4 5 6 7 8 910 // string indices - h e l l // string value - ^-^ // target slice {start: 2, end: 4} - -D: 0 1 2 3 4 5 6 7 8 910 // string indices - h e l w o r l d // string value - ^---^ // target slice {start: 2, end: 5} - -E: 0 1 2 3 4 5 6 7 8 910 // string indices - h w o r l d // string value - ^-^ // target slice {start: 1, end: 3} - -F: 0 1 2 3 4 5 6 7 8 910 // string indices - l l o w o r l d // string value - ^-------^ // target slice {start: 0, end: 5} - */ - -import cloneDeep from 'lodash.clonedeep' -import {AppBskyFeedPost} from '@atproto/api' -import {removeExcessNewlines} from './rich-text-sanitize' - -export type Entity = AppBskyFeedPost.Entity -export interface RichTextOpts { - cleanNewlines?: boolean -} - -export class RichText { - constructor( - public text: string, - public entities?: Entity[], - opts?: RichTextOpts, - ) { - if (opts?.cleanNewlines) { - removeExcessNewlines(this).copyInto(this) - } - } - - clone() { - return new RichText(this.text, cloneDeep(this.entities)) - } - - copyInto(target: RichText) { - target.text = this.text - target.entities = cloneDeep(this.entities) - } - - insert(insertIndex: number, insertText: string) { - this.text = - this.text.slice(0, insertIndex) + - insertText + - this.text.slice(insertIndex) - - if (!this.entities?.length) { - return this - } - - const numCharsAdded = insertText.length - for (const ent of this.entities) { - // see comment at top of file for labels of each scenario - // scenario A (before) - if (insertIndex <= ent.index.start) { - // move both by num added - ent.index.start += numCharsAdded - ent.index.end += numCharsAdded - } - // scenario B (inner) - else if (insertIndex >= ent.index.start && insertIndex < ent.index.end) { - // move end by num added - ent.index.end += numCharsAdded - } - // scenario C (after) - // noop - } - return this - } - - delete(removeStartIndex: number, removeEndIndex: number) { - this.text = - this.text.slice(0, removeStartIndex) + this.text.slice(removeEndIndex) - - if (!this.entities?.length) { - return this - } - - const numCharsRemoved = removeEndIndex - removeStartIndex - for (const ent of this.entities) { - // see comment at top of file for labels of each scenario - // scenario A (entirely outer) - if ( - removeStartIndex <= ent.index.start && - removeEndIndex >= ent.index.end - ) { - // delete slice (will get removed in final pass) - ent.index.start = 0 - ent.index.end = 0 - } - // scenario B (entirely after) - else if (removeStartIndex > ent.index.end) { - // noop - } - // scenario C (partially after) - else if ( - removeStartIndex > ent.index.start && - removeStartIndex <= ent.index.end && - removeEndIndex > ent.index.end - ) { - // move end to remove start - ent.index.end = removeStartIndex - } - // scenario D (entirely inner) - else if ( - removeStartIndex >= ent.index.start && - removeEndIndex <= ent.index.end - ) { - // move end by num removed - ent.index.end -= numCharsRemoved - } - // scenario E (partially before) - else if ( - removeStartIndex < ent.index.start && - removeEndIndex >= ent.index.start && - removeEndIndex <= ent.index.end - ) { - // move start to remove-start index, move end by num removed - ent.index.start = removeStartIndex - ent.index.end -= numCharsRemoved - } - // scenario F (entirely before) - else if (removeEndIndex < ent.index.start) { - // move both by num removed - ent.index.start -= numCharsRemoved - ent.index.end -= numCharsRemoved - } - } - - // filter out any entities that were made irrelevant - this.entities = this.entities.filter(ent => ent.index.start < ent.index.end) - return this - } -} diff --git a/src/lib/styles.ts b/src/lib/styles.ts index aa255b21..409c7754 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -71,6 +71,7 @@ export const s = StyleSheet.create({ borderBottom1: {borderBottomWidth: 1}, borderLeft1: {borderLeftWidth: 1}, hidden: {display: 'none'}, + dimmed: {opacity: 0.5}, // font weights fw600: {fontWeight: '600'}, diff --git a/src/platform/polyfills.ts b/src/platform/polyfills.ts index 3dbd1398..a64c2c33 100644 --- a/src/platform/polyfills.ts +++ b/src/platform/polyfills.ts @@ -1,3 +1,5 @@ +import 'fast-text-encoding' +import Graphemer from 'graphemer' export {} /** @@ -48,3 +50,18 @@ globalThis.atob = (str: string): string => { } return result } + +const splitter = new Graphemer() +globalThis.Intl = globalThis.Intl || {} + +// @ts-ignore we're polyfilling -prf +globalThis.Intl.Segmenter = + // @ts-ignore we're polyfilling -prf + globalThis.Intl.Segmenter || + class Segmenter { + constructor() {} + // NOTE + // this is not a precisely correct polyfill but it's sufficient for our needs + // -prf + segment = splitter.iterateGraphemes + } diff --git a/src/platform/polyfills.web.ts b/src/platform/polyfills.web.ts index 7a42f488..e46963a6 100644 --- a/src/platform/polyfills.web.ts +++ b/src/platform/polyfills.web.ts @@ -2,3 +2,11 @@ // @ts-ignore whatever typescript wants to complain about here, I dont care about -prf window.setImmediate = (cb: () => void) => setTimeout(cb, 0) + +// @ts-ignore not on the TS signature due to bad support -prf +if (!globalThis.Intl?.Segmenter) { + // NOTE loading as a separate script to reduce main bundle size, as this is only needed in FF -prf + const script = document.createElement('script') + script.setAttribute('src', '/static/js/intl-segmenter-polyfill.min.js') + document.head.appendChild(script) +} diff --git a/src/routes.ts b/src/routes.ts index 6c02a7c5..167efcfb 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -9,7 +9,7 @@ export const router = new Router({ ProfileFollowers: '/profile/:name/followers', ProfileFollows: '/profile/:name/follows', PostThread: '/profile/:name/post/:rkey', - PostUpvotedBy: '/profile/:name/post/:rkey/upvoted-by', + PostLikedBy: '/profile/:name/post/:rkey/liked-by', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', Debug: '/sys/debug', Log: '/sys/log', diff --git a/src/state/index.ts b/src/state/index.ts index f0713efe..4755c28f 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -1,6 +1,6 @@ import {autorun} from 'mobx' import {AppState, Platform} from 'react-native' -import {AtpAgent} from '@atproto/api' +import {BskyAgent} from '@atproto/api' import {RootStoreModel} from './models/root-store' import * as apiPolyfill from 'lib/api/api-polyfill' import * as storage from 'lib/storage' @@ -19,7 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) { apiPolyfill.doPolyfill() - rootStore = new RootStoreModel(new AtpAgent({service: serviceUri})) + rootStore = new RootStoreModel(new BskyAgent({service: serviceUri})) try { data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} rootStore.log.debug('Initial hydrate', {hasSession: !!data.session}) diff --git a/src/state/models/cache/image-sizes.ts b/src/state/models/cache/image-sizes.ts index ff048627..2fd6e001 100644 --- a/src/state/models/cache/image-sizes.ts +++ b/src/state/models/cache/image-sizes.ts @@ -3,7 +3,7 @@ import {Dim} from 'lib/media/manip' export class ImageSizesCache { sizes: Map = new Map() - private activeRequests: Map> = new Map() + activeRequests: Map> = new Map() constructor() {} diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts index 725b7841..14eeaae2 100644 --- a/src/state/models/cache/my-follows.ts +++ b/src/state/models/cache/my-follows.ts @@ -1,15 +1,12 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' +import {FollowRecord, AppBskyActorDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' const CACHE_TTL = 1000 * 60 * 60 // hourly type FollowsListResponse = Awaited> type FollowsListResponseRecord = FollowsListResponse['records'][0] -type Profile = - | AppBskyActorProfile.ViewBasic - | AppBskyActorProfile.View - | AppBskyActorRef.WithInfo +type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView /** * This model is used to maintain a synced local cache of the user's @@ -53,21 +50,21 @@ export class MyFollowsCache { fetch = bundleAsync(async () => { this.rootStore.log.debug('MyFollowsModel:fetch running full fetch') - let before + let rkeyStart let records: FollowsListResponseRecord[] = [] do { const res: FollowsListResponse = - await this.rootStore.api.app.bsky.graph.follow.list({ - user: this.rootStore.me.did, - before, + await this.rootStore.agent.app.bsky.graph.follow.list({ + repo: this.rootStore.me.did, + rkeyStart, }) records = records.concat(res.records) - before = res.cursor - } while (typeof before !== 'undefined') + rkeyStart = res.cursor + } while (typeof rkeyStart !== 'undefined') runInAction(() => { this.followDidToRecordMap = {} for (const record of records) { - this.followDidToRecordMap[record.value.subject.did] = record.uri + this.followDidToRecordMap[record.value.subject] = record.uri } this.lastSync = Date.now() this.myDid = this.rootStore.me.did diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts index 241338a1..27cee850 100644 --- a/src/state/models/discovery/foafs.ts +++ b/src/state/models/discovery/foafs.ts @@ -1,15 +1,15 @@ -import {AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import {makeAutoObservable, runInAction} from 'mobx' import sampleSize from 'lodash.samplesize' import {bundleAsync} from 'lib/async/bundle' import {RootStoreModel} from '../root-store' -export type RefWithInfoAndFollowers = AppBskyActorRef.WithInfo & { - followers: AppBskyActorProfile.View[] +export type RefWithInfoAndFollowers = AppBskyActorDefs.ProfileViewBasic & { + followers: AppBskyActorDefs.ProfileView[] } -export type ProfileViewFollows = AppBskyActorProfile.View & { - follows: AppBskyActorRef.WithInfo[] +export type ProfileViewFollows = AppBskyActorDefs.ProfileView & { + follows: AppBskyActorDefs.ProfileViewBasic[] } export class FoafsModel { @@ -51,14 +51,14 @@ export class FoafsModel { this.popular.length = 0 // fetch their profiles - const profiles = await this.rootStore.api.app.bsky.actor.getProfiles({ + const profiles = await this.rootStore.agent.getProfiles({ actors: this.sources, }) // fetch their follows const results = await Promise.allSettled( this.sources.map(source => - this.rootStore.api.app.bsky.graph.getFollows({user: source}), + this.rootStore.agent.getFollows({actor: source}), ), ) diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts index cf8e2dd7..91c5efd0 100644 --- a/src/state/models/discovery/suggested-actors.ts +++ b/src/state/models/discovery/suggested-actors.ts @@ -1,5 +1,5 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorProfile as Profile} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import shuffle from 'lodash.shuffle' import {RootStoreModel} from '../root-store' import {cleanError} from 'lib/strings/errors' @@ -8,7 +8,9 @@ import {SUGGESTED_FOLLOWS} from 'lib/constants' const PAGE_SIZE = 30 -export type SuggestedActor = Profile.ViewBasic | Profile.View +export type SuggestedActor = + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileView export class SuggestedActorsModel { // state @@ -20,7 +22,7 @@ export class SuggestedActorsModel { hasMore = true loadMoreCursor?: string - private hardCodedSuggestions: SuggestedActor[] | undefined + hardCodedSuggestions: SuggestedActor[] | undefined // data suggestions: SuggestedActor[] = [] @@ -82,7 +84,7 @@ export class SuggestedActorsModel { this.loadMoreCursor = undefined } else { // pull from the PDS' algo - res = await this.rootStore.api.app.bsky.actor.getSuggestions({ + res = await this.rootStore.agent.app.bsky.actor.getSuggestions({ limit: this.pageSize, cursor: this.loadMoreCursor, }) @@ -104,7 +106,7 @@ export class SuggestedActorsModel { } }) - private async fetchHardcodedSuggestions() { + async fetchHardcodedSuggestions() { if (this.hardCodedSuggestions) { return } @@ -118,9 +120,9 @@ export class SuggestedActorsModel { ] // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`) - let profiles: Profile.View[] = [] + let profiles: AppBskyActorDefs.ProfileView[] = [] do { - const res = await this.rootStore.api.app.bsky.actor.getProfiles({ + const res = await this.rootStore.agent.getProfiles({ actors: actors.splice(0, 25), }) profiles = profiles.concat(res.data.profiles) @@ -152,13 +154,13 @@ export class SuggestedActorsModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts index 083863fe..8b62c958 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -1,32 +1,29 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetTimeline as GetTimeline, - AppBskyFeedFeedViewPost, + AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedGetAuthorFeed as GetAuthorFeed, + RichText, } from '@atproto/api' import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' import sampleSize from 'lodash.samplesize' -type FeedViewPost = AppBskyFeedFeedViewPost.Main -type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost -type PostView = AppBskyFeedPost.View -import {AtUri} from '../../third-party/uri' import {RootStoreModel} from './root-store' -import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' -import {RichText} from 'lib/strings/rich-text' import {SUGGESTED_FOLLOWS} from 'lib/constants' import { getCombinedCursors, getMultipleAuthorsPosts, mergePosts, } from 'lib/api/build-suggested-posts' - import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' -const PAGE_SIZE = 30 +type FeedViewPost = AppBskyFeedDefs.FeedViewPost +type ReasonRepost = AppBskyFeedDefs.ReasonRepost +type PostView = AppBskyFeedDefs.PostView +const PAGE_SIZE = 30 let _idCounter = 0 export class FeedItemModel { @@ -51,11 +48,7 @@ export class FeedItemModel { const valid = AppBskyFeedPost.validateRecord(this.post.record) if (valid.success) { this.postRecord = this.post.record - this.richText = new RichText( - this.postRecord.text, - this.postRecord.entities, - {cleanNewlines: true}, - ) + this.richText = new RichText(this.postRecord, {cleanNewlines: true}) } else { rootStore.log.warn( 'Received an invalid app.bsky.feed.post record', @@ -82,7 +75,7 @@ export class FeedItemModel { copyMetrics(v: FeedViewPost) { this.post.replyCount = v.post.replyCount this.post.repostCount = v.post.repostCount - this.post.upvoteCount = v.post.upvoteCount + this.post.likeCount = v.post.likeCount this.post.viewer = v.post.viewer } @@ -92,68 +85,43 @@ export class FeedItemModel { } } - async toggleUpvote() { - const wasUpvoted = !!this.post.viewer.upvote - const wasDownvoted = !!this.post.viewer.downvote - const res = await this.rootStore.api.app.bsky.feed.setVote({ - subject: { - uri: this.post.uri, - cid: this.post.cid, - }, - direction: wasUpvoted ? 'none' : 'up', - }) - runInAction(() => { - if (wasDownvoted) { - this.post.downvoteCount-- - } - if (wasUpvoted) { - this.post.upvoteCount-- - } else { - this.post.upvoteCount++ - } - this.post.viewer.upvote = res.data.upvote - this.post.viewer.downvote = res.data.downvote - }) - } - - async toggleDownvote() { - const wasUpvoted = !!this.post.viewer.upvote - const wasDownvoted = !!this.post.viewer.downvote - const res = await this.rootStore.api.app.bsky.feed.setVote({ - subject: { - uri: this.post.uri, - cid: this.post.cid, - }, - direction: wasDownvoted ? 'none' : 'down', - }) - runInAction(() => { - if (wasUpvoted) { - this.post.upvoteCount-- - } - if (wasDownvoted) { - this.post.downvoteCount-- - } else { - this.post.downvoteCount++ - } - this.post.viewer.upvote = res.data.upvote - this.post.viewer.downvote = res.data.downvote - }) + async toggleLike() { + if (this.post.viewer?.like) { + await this.rootStore.agent.deleteLike(this.post.viewer.like) + runInAction(() => { + this.post.likeCount = this.post.likeCount || 0 + this.post.viewer = this.post.viewer || {} + this.post.likeCount-- + this.post.viewer.like = undefined + }) + } else { + const res = await this.rootStore.agent.like(this.post.uri, this.post.cid) + runInAction(() => { + this.post.likeCount = this.post.likeCount || 0 + this.post.viewer = this.post.viewer || {} + this.post.likeCount++ + this.post.viewer.like = res.uri + }) + } } async toggleRepost() { - if (this.post.viewer.repost) { - await apilib.unrepost(this.rootStore, this.post.viewer.repost) + if (this.post.viewer?.repost) { + await this.rootStore.agent.deleteRepost(this.post.viewer.repost) runInAction(() => { + this.post.repostCount = this.post.repostCount || 0 + this.post.viewer = this.post.viewer || {} this.post.repostCount-- this.post.viewer.repost = undefined }) } else { - const res = await apilib.repost( - this.rootStore, + const res = await this.rootStore.agent.repost( this.post.uri, this.post.cid, ) runInAction(() => { + this.post.repostCount = this.post.repostCount || 0 + this.post.viewer = this.post.viewer || {} this.post.repostCount++ this.post.viewer.repost = res.uri }) @@ -161,10 +129,7 @@ export class FeedItemModel { } async delete() { - await this.rootStore.api.app.bsky.feed.post.delete({ - did: this.post.author.did, - rkey: new AtUri(this.post.uri).rkey, - }) + await this.rootStore.agent.deletePost(this.post.uri) this.rootStore.emitPostDeleted(this.post.uri) } } @@ -250,7 +215,7 @@ export class FeedModel { tuner = new FeedTuner() // used to linearize async modifications to state - private lock = new AwaitLock() + lock = new AwaitLock() // data slices: FeedSliceModel[] = [] @@ -291,8 +256,8 @@ export class FeedModel { const params = this.params as GetAuthorFeed.QueryParams const item = slice.rootItem const isRepost = - item?.reasonRepost?.by?.handle === params.author || - item?.reasonRepost?.by?.did === params.author + item?.reasonRepost?.by?.handle === params.actor || + item?.reasonRepost?.by?.did === params.actor return ( !item.reply || // not a reply isRepost || // but allow if it's a repost @@ -338,7 +303,7 @@ export class FeedModel { return this.setup() } - private get feedTuners() { + get feedTuners() { if (this.feedType === 'goodstuff') { return [ FeedTuner.dedupReposts, @@ -406,7 +371,7 @@ export class FeedModel { this._xLoading() try { const res = await this._getFeed({ - before: this.loadMoreCursor, + cursor: this.loadMoreCursor, limit: PAGE_SIZE, }) await this._appendAll(res) @@ -439,7 +404,7 @@ export class FeedModel { try { do { const res: GetTimeline.Response = await this._getFeed({ - before: cursor, + cursor, limit: Math.min(numToFetch, 100), }) if (res.data.feed.length === 0) { @@ -478,14 +443,18 @@ export class FeedModel { new FeedSliceModel(this.rootStore, `item-${_idCounter++}`, slice), ) if (autoPrepend) { - this.slices = nextSlicesModels.concat( - this.slices.filter(slice1 => - nextSlicesModels.find(slice2 => slice1.uri === slice2.uri), - ), - ) - this.setHasNewLatest(false) + runInAction(() => { + this.slices = nextSlicesModels.concat( + this.slices.filter(slice1 => + nextSlicesModels.find(slice2 => slice1.uri === slice2.uri), + ), + ) + this.setHasNewLatest(false) + }) } else { - this.nextSlices = nextSlicesModels + runInAction(() => { + this.nextSlices = nextSlicesModels + }) this.setHasNewLatest(true) } } else { @@ -519,13 +488,13 @@ export class FeedModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -538,14 +507,12 @@ export class FeedModel { // helper functions // = - private async _replaceAll( - res: GetTimeline.Response | GetAuthorFeed.Response, - ) { + async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) { this.pollCursor = res.data.feed[0]?.post.uri return this._appendAll(res, true) } - private async _appendAll( + async _appendAll( res: GetTimeline.Response | GetAuthorFeed.Response, replace = false, ) { @@ -572,7 +539,7 @@ export class FeedModel { }) } - private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { for (const item of res.data.feed) { const existingSlice = this.slices.find(slice => slice.containsUri(item.post.uri), @@ -596,7 +563,7 @@ export class FeedModel { const responses = await getMultipleAuthorsPosts( this.rootStore, sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20), - params.before, + params.cursor, 20, ) const combinedCursor = getCombinedCursors(responses) @@ -611,9 +578,7 @@ export class FeedModel { headers: lastHeaders, } } else if (this.feedType === 'home') { - return this.rootStore.api.app.bsky.feed.getTimeline( - params as GetTimeline.QueryParams, - ) + return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) } else if (this.feedType === 'goodstuff') { const res = await getGoodStuff( this.rootStore.session.currentSession?.accessJwt || '', @@ -624,7 +589,7 @@ export class FeedModel { ) return res } else { - return this.rootStore.api.app.bsky.feed.getAuthorFeed( + return this.rootStore.agent.getAuthorFeed( params as GetAuthorFeed.QueryParams, ) } diff --git a/src/state/models/votes-view.ts b/src/state/models/likes-view.ts similarity index 77% rename from src/state/models/votes-view.ts rename to src/state/models/likes-view.ts index ad8698d2..5f9df692 100644 --- a/src/state/models/votes-view.ts +++ b/src/state/models/likes-view.ts @@ -1,6 +1,6 @@ import {makeAutoObservable, runInAction} from 'mobx' import {AtUri} from '../../third-party/uri' -import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api' +import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from 'lib/strings/errors' import {bundleAsync} from 'lib/async/bundle' @@ -8,24 +8,24 @@ import * as apilib from 'lib/api/index' const PAGE_SIZE = 30 -export type VoteItem = GetVotes.Vote +export type LikeItem = GetLikes.Like -export class VotesViewModel { +export class LikesViewModel { // state isLoading = false isRefreshing = false hasLoaded = false error = '' resolvedUri = '' - params: GetVotes.QueryParams + params: GetLikes.QueryParams hasMore = true loadMoreCursor?: string // data uri: string = '' - votes: VoteItem[] = [] + likes: LikeItem[] = [] - constructor(public rootStore: RootStoreModel, params: GetVotes.QueryParams) { + constructor(public rootStore: RootStoreModel, params: GetLikes.QueryParams) { makeAutoObservable( this, { @@ -68,9 +68,9 @@ export class VotesViewModel { const params = Object.assign({}, this.params, { uri: this.resolvedUri, limit: PAGE_SIZE, - before: replace ? undefined : this.loadMoreCursor, + cursor: replace ? undefined : this.loadMoreCursor, }) - const res = await this.rootStore.api.app.bsky.feed.getVotes(params) + const res = await this.rootStore.agent.getLikes(params) if (replace) { this._replaceAll(res) } else { @@ -85,13 +85,13 @@ export class VotesViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -104,7 +104,7 @@ export class VotesViewModel { // helper functions // = - private async _resolveUri() { + async _resolveUri() { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { @@ -118,14 +118,14 @@ export class VotesViewModel { }) } - private _replaceAll(res: GetVotes.Response) { - this.votes = [] + _replaceAll(res: GetLikes.Response) { + this.likes = [] this._appendAll(res) } - private _appendAll(res: GetVotes.Response) { + _appendAll(res: GetLikes.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor - this.votes = this.votes.concat(res.data.votes) + this.likes = this.likes.concat(res.data.likes) } } diff --git a/src/state/models/log.ts b/src/state/models/log.ts index ed701dc6..d8061713 100644 --- a/src/state/models/log.ts +++ b/src/state/models/log.ts @@ -1,5 +1,5 @@ import {makeAutoObservable} from 'mobx' -import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' +// import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' TODO const MAX_ENTRIES = 300 @@ -32,7 +32,7 @@ export class LogModel { makeAutoObservable(this) } - private add(entry: LogEntry) { + add(entry: LogEntry) { this.entries.push(entry) while (this.entries.length > MAX_ENTRIES) { this.entries = this.entries.slice(50) @@ -79,14 +79,14 @@ export class LogModel { function detailsToStr(details?: any) { if (details && typeof details !== 'string') { if ( - details instanceof XRPCInvalidResponseError || + // details instanceof XRPCInvalidResponseError || TODO details.constructor.name === 'XRPCInvalidResponseError' ) { return `The server gave an ill-formatted response.\nMethod: ${ details.lexiconNsid }.\nError: ${details.validationError.toString()}` } else if ( - details instanceof XRPCError || + // details instanceof XRPCError || TODO details.constructor.name === 'XRPCError' ) { return `An XRPC error occurred.\nStatus: ${details.status}\nError: ${details.error}\nMessage: ${details.message}` diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 12074915..5f670b8f 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -85,7 +85,7 @@ export class MeModel { if (sess.hasSession) { this.did = sess.currentSession?.did || '' this.handle = sess.currentSession?.handle || '' - const profile = await this.rootStore.api.app.bsky.actor.getProfile({ + const profile = await this.rootStore.agent.getProfile({ actor: this.did, }) runInAction(() => { diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts index e88af590..4f7a52fd 100644 --- a/src/state/models/notifications-view.ts +++ b/src/state/models/notifications-view.ts @@ -1,11 +1,10 @@ import {makeAutoObservable, runInAction} from 'mobx' import { - AppBskyNotificationList as ListNotifications, - AppBskyActorRef as ActorRef, + AppBskyNotificationListNotifications as ListNotifications, + AppBskyActorDefs, AppBskyFeedPost, AppBskyFeedRepost, - AppBskyFeedVote, - AppBskyGraphAssertion, + AppBskyFeedLike, AppBskyGraphFollow, } from '@atproto/api' import AwaitLock from 'await-lock' @@ -28,8 +27,7 @@ export interface GroupedNotification extends ListNotifications.Notification { type SupportedRecord = | AppBskyFeedPost.Record | AppBskyFeedRepost.Record - | AppBskyFeedVote.Record - | AppBskyGraphAssertion.Record + | AppBskyFeedLike.Record | AppBskyGraphFollow.Record export class NotificationsViewItemModel { @@ -39,11 +37,10 @@ export class NotificationsViewItemModel { // data uri: string = '' cid: string = '' - author: ActorRef.WithInfo = { + author: AppBskyActorDefs.ProfileViewBasic = { did: '', handle: '', avatar: '', - declaration: {cid: '', actorType: ''}, } reason: string = '' reasonSubject?: string @@ -86,8 +83,8 @@ export class NotificationsViewItemModel { } } - get isUpvote() { - return this.reason === 'vote' + get isLike() { + return this.reason === 'like' } get isRepost() { @@ -102,16 +99,22 @@ export class NotificationsViewItemModel { return this.reason === 'reply' } + get isQuote() { + return this.reason === 'quote' + } + get isFollow() { return this.reason === 'follow' } - get isAssertion() { - return this.reason === 'assertion' - } - get needsAdditionalData() { - if (this.isUpvote || this.isRepost || this.isReply || this.isMention) { + if ( + this.isLike || + this.isRepost || + this.isReply || + this.isQuote || + this.isMention + ) { return !this.additionalPost } return false @@ -124,7 +127,7 @@ export class NotificationsViewItemModel { const record = this.record if ( AppBskyFeedRepost.isRecord(record) || - AppBskyFeedVote.isRecord(record) + AppBskyFeedLike.isRecord(record) ) { return record.subject.uri } @@ -135,8 +138,7 @@ export class NotificationsViewItemModel { for (const ns of [ AppBskyFeedPost, AppBskyFeedRepost, - AppBskyFeedVote, - AppBskyGraphAssertion, + AppBskyFeedLike, AppBskyGraphFollow, ]) { if (ns.isRecord(v)) { @@ -163,9 +165,9 @@ export class NotificationsViewItemModel { return } let postUri - if (this.isReply || this.isMention) { + if (this.isReply || this.isQuote || this.isMention) { postUri = this.uri - } else if (this.isUpvote || this.isRepost) { + } else if (this.isLike || this.isRepost) { postUri = this.subjectUri } if (postUri) { @@ -194,7 +196,7 @@ export class NotificationsViewModel { loadMoreCursor?: string // used to linearize async modifications to state - private lock = new AwaitLock() + lock = new AwaitLock() // data notifications: NotificationsViewItemModel[] = [] @@ -266,7 +268,7 @@ export class NotificationsViewModel { const params = Object.assign({}, this.params, { limit: PAGE_SIZE, }) - const res = await this.rootStore.api.app.bsky.notification.list(params) + const res = await this.rootStore.agent.listNotifications(params) await this._replaceAll(res) this._xIdle() } catch (e: any) { @@ -297,9 +299,9 @@ export class NotificationsViewModel { try { const params = Object.assign({}, this.params, { limit: PAGE_SIZE, - before: this.loadMoreCursor, + cursor: this.loadMoreCursor, }) - const res = await this.rootStore.api.app.bsky.notification.list(params) + const res = await this.rootStore.agent.listNotifications(params) await this._appendAll(res) this._xIdle() } catch (e: any) { @@ -325,7 +327,7 @@ export class NotificationsViewModel { try { this._xLoading() try { - const res = await this.rootStore.api.app.bsky.notification.list({ + const res = await this.rootStore.agent.listNotifications({ limit: PAGE_SIZE, }) await this._prependAll(res) @@ -357,8 +359,8 @@ export class NotificationsViewModel { try { do { const res: ListNotifications.Response = - await this.rootStore.api.app.bsky.notification.list({ - before: cursor, + await this.rootStore.agent.listNotifications({ + cursor, limit: Math.min(numToFetch, 100), }) if (res.data.notifications.length === 0) { @@ -390,7 +392,7 @@ export class NotificationsViewModel { */ loadUnreadCount = bundleAsync(async () => { const old = this.unreadCount - const res = await this.rootStore.api.app.bsky.notification.getCount() + const res = await this.rootStore.agent.countUnreadNotifications() runInAction(() => { this.unreadCount = res.data.count }) @@ -408,9 +410,7 @@ export class NotificationsViewModel { for (const notif of this.notifications) { notif.isRead = true } - await this.rootStore.api.app.bsky.notification.updateSeen({ - seenAt: new Date().toISOString(), - }) + await this.rootStore.agent.updateSeenNotifications() } catch (e: any) { this.rootStore.log.warn('Failed to update notifications read state', e) } @@ -418,7 +418,7 @@ export class NotificationsViewModel { async getNewMostRecent(): Promise { let old = this.mostRecentNotificationUri - const res = await this.rootStore.api.app.bsky.notification.list({ + const res = await this.rootStore.agent.listNotifications({ limit: 1, }) if (!res.data.notifications[0] || old === res.data.notifications[0].uri) { @@ -437,13 +437,13 @@ export class NotificationsViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -456,14 +456,14 @@ export class NotificationsViewModel { // helper functions // = - private async _replaceAll(res: ListNotifications.Response) { + async _replaceAll(res: ListNotifications.Response) { if (res.data.notifications[0]) { this.mostRecentNotificationUri = res.data.notifications[0].uri } return this._appendAll(res, true) } - private async _appendAll(res: ListNotifications.Response, replace = false) { + async _appendAll(res: ListNotifications.Response, replace = false) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor const promises = [] @@ -494,7 +494,7 @@ export class NotificationsViewModel { }) } - private async _prependAll(res: ListNotifications.Response) { + async _prependAll(res: ListNotifications.Response) { const promises = [] const itemModels: NotificationsViewItemModel[] = [] const dedupedNotifs = res.data.notifications.filter( @@ -525,7 +525,7 @@ export class NotificationsViewModel { }) } - private _updateAll(res: ListNotifications.Response) { + _updateAll(res: ListNotifications.Response) { for (const item of res.data.notifications) { const existingItem = this.notifications.find(item2 => isEq(item, item2)) if (existingItem) { diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts index d58ee691..c5395b9c 100644 --- a/src/state/models/post-thread-view.ts +++ b/src/state/models/post-thread-view.ts @@ -2,12 +2,13 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetPostThread as GetPostThread, AppBskyFeedPost as FeedPost, + AppBskyFeedDefs, + RichText, } from '@atproto/api' import {AtUri} from '../../third-party/uri' import {RootStoreModel} from './root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' -import {RichText} from 'lib/strings/rich-text' function* reactKeyGenerator(): Generator { let counter = 0 @@ -26,10 +27,10 @@ export class PostThreadViewPostModel { _hasMore = false // data - post: FeedPost.View + post: AppBskyFeedDefs.PostView postRecord?: FeedPost.Record - parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost - replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[] + parent?: PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost + replies?: (PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost)[] richText?: RichText get uri() { @@ -43,7 +44,7 @@ export class PostThreadViewPostModel { constructor( public rootStore: RootStoreModel, reactKey: string, - v: GetPostThread.ThreadViewPost, + v: AppBskyFeedDefs.ThreadViewPost, ) { this._reactKey = reactKey this.post = v.post @@ -51,11 +52,7 @@ export class PostThreadViewPostModel { const valid = FeedPost.validateRecord(this.post.record) if (valid.success) { this.postRecord = this.post.record - this.richText = new RichText( - this.postRecord.text, - this.postRecord.entities, - {cleanNewlines: true}, - ) + this.richText = new RichText(this.postRecord, {cleanNewlines: true}) } else { rootStore.log.warn( 'Received an invalid app.bsky.feed.post record', @@ -74,14 +71,14 @@ export class PostThreadViewPostModel { assignTreeModels( keyGen: Generator, - v: GetPostThread.ThreadViewPost, + v: AppBskyFeedDefs.ThreadViewPost, higlightedPostUri: string, includeParent = true, includeChildren = true, ) { // parents if (includeParent && v.parent) { - if (GetPostThread.isThreadViewPost(v.parent)) { + if (AppBskyFeedDefs.isThreadViewPost(v.parent)) { const parentModel = new PostThreadViewPostModel( this.rootStore, keyGen.next().value, @@ -100,7 +97,7 @@ export class PostThreadViewPostModel { ) } this.parent = parentModel - } else if (GetPostThread.isNotFoundPost(v.parent)) { + } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) { this.parent = v.parent } } @@ -108,7 +105,7 @@ export class PostThreadViewPostModel { if (includeChildren && v.replies) { const replies = [] for (const item of v.replies) { - if (GetPostThread.isThreadViewPost(item)) { + if (AppBskyFeedDefs.isThreadViewPost(item)) { const itemModel = new PostThreadViewPostModel( this.rootStore, keyGen.next().value, @@ -128,7 +125,7 @@ export class PostThreadViewPostModel { ) } replies.push(itemModel) - } else if (GetPostThread.isNotFoundPost(item)) { + } else if (AppBskyFeedDefs.isNotFoundPost(item)) { replies.push(item) } } @@ -136,68 +133,43 @@ export class PostThreadViewPostModel { } } - async toggleUpvote() { - const wasUpvoted = !!this.post.viewer.upvote - const wasDownvoted = !!this.post.viewer.downvote - const res = await this.rootStore.api.app.bsky.feed.setVote({ - subject: { - uri: this.post.uri, - cid: this.post.cid, - }, - direction: wasUpvoted ? 'none' : 'up', - }) - runInAction(() => { - if (wasDownvoted) { - this.post.downvoteCount-- - } - if (wasUpvoted) { - this.post.upvoteCount-- - } else { - this.post.upvoteCount++ - } - this.post.viewer.upvote = res.data.upvote - this.post.viewer.downvote = res.data.downvote - }) - } - - async toggleDownvote() { - const wasUpvoted = !!this.post.viewer.upvote - const wasDownvoted = !!this.post.viewer.downvote - const res = await this.rootStore.api.app.bsky.feed.setVote({ - subject: { - uri: this.post.uri, - cid: this.post.cid, - }, - direction: wasDownvoted ? 'none' : 'down', - }) - runInAction(() => { - if (wasUpvoted) { - this.post.upvoteCount-- - } - if (wasDownvoted) { - this.post.downvoteCount-- - } else { - this.post.downvoteCount++ - } - this.post.viewer.upvote = res.data.upvote - this.post.viewer.downvote = res.data.downvote - }) + async toggleLike() { + if (this.post.viewer?.like) { + await this.rootStore.agent.deleteLike(this.post.viewer.like) + runInAction(() => { + this.post.likeCount = this.post.likeCount || 0 + this.post.viewer = this.post.viewer || {} + this.post.likeCount-- + this.post.viewer.like = undefined + }) + } else { + const res = await this.rootStore.agent.like(this.post.uri, this.post.cid) + runInAction(() => { + this.post.likeCount = this.post.likeCount || 0 + this.post.viewer = this.post.viewer || {} + this.post.likeCount++ + this.post.viewer.like = res.uri + }) + } } async toggleRepost() { - if (this.post.viewer.repost) { - await apilib.unrepost(this.rootStore, this.post.viewer.repost) + if (this.post.viewer?.repost) { + await this.rootStore.agent.deleteRepost(this.post.viewer.repost) runInAction(() => { + this.post.repostCount = this.post.repostCount || 0 + this.post.viewer = this.post.viewer || {} this.post.repostCount-- this.post.viewer.repost = undefined }) } else { - const res = await apilib.repost( - this.rootStore, + const res = await this.rootStore.agent.repost( this.post.uri, this.post.cid, ) runInAction(() => { + this.post.repostCount = this.post.repostCount || 0 + this.post.viewer = this.post.viewer || {} this.post.repostCount++ this.post.viewer.repost = res.uri }) @@ -205,10 +177,7 @@ export class PostThreadViewPostModel { } async delete() { - await this.rootStore.api.app.bsky.feed.post.delete({ - did: this.post.author.did, - rkey: new AtUri(this.post.uri).rkey, - }) + await this.rootStore.agent.deletePost(this.post.uri) this.rootStore.emitPostDeleted(this.post.uri) } } @@ -301,14 +270,14 @@ export class PostThreadViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' this.notFound = false } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -322,7 +291,7 @@ export class PostThreadViewModel { // loader functions // = - private async _resolveUri() { + async _resolveUri() { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { @@ -336,10 +305,10 @@ export class PostThreadViewModel { }) } - private async _load(isRefreshing = false) { + async _load(isRefreshing = false) { this._xLoading(isRefreshing) try { - const res = await this.rootStore.api.app.bsky.feed.getPostThread( + const res = await this.rootStore.agent.getPostThread( Object.assign({}, this.params, {uri: this.resolvedUri}), ) this._replaceAll(res) @@ -349,18 +318,18 @@ export class PostThreadViewModel { } } - private _replaceAll(res: GetPostThread.Response) { + _replaceAll(res: GetPostThread.Response) { sortThread(res.data.thread) const keyGen = reactKeyGenerator() const thread = new PostThreadViewPostModel( this.rootStore, keyGen.next().value, - res.data.thread as GetPostThread.ThreadViewPost, + res.data.thread as AppBskyFeedDefs.ThreadViewPost, ) thread._isHighlightedPost = true thread.assignTreeModels( keyGen, - res.data.thread as GetPostThread.ThreadViewPost, + res.data.thread as AppBskyFeedDefs.ThreadViewPost, thread.uri, ) this.thread = thread @@ -368,25 +337,25 @@ export class PostThreadViewModel { } type MaybePost = - | GetPostThread.ThreadViewPost - | GetPostThread.NotFoundPost + | AppBskyFeedDefs.ThreadViewPost + | AppBskyFeedDefs.NotFoundPost | {[k: string]: unknown; $type: string} function sortThread(post: MaybePost) { if (post.notFound) { return } - post = post as GetPostThread.ThreadViewPost + post = post as AppBskyFeedDefs.ThreadViewPost if (post.replies) { post.replies.sort((a: MaybePost, b: MaybePost) => { - post = post as GetPostThread.ThreadViewPost + post = post as AppBskyFeedDefs.ThreadViewPost if (a.notFound) { return 1 } if (b.notFound) { return -1 } - a = a as GetPostThread.ThreadViewPost - b = b as GetPostThread.ThreadViewPost + a = a as AppBskyFeedDefs.ThreadViewPost + b = b as AppBskyFeedDefs.ThreadViewPost const aIsByOp = a.post.author.did === post.post.author.did const bIsByOp = b.post.author.did === post.post.author.did if (aIsByOp && bIsByOp) { diff --git a/src/state/models/post.ts b/src/state/models/post.ts index 749e98bb..c7f2896b 100644 --- a/src/state/models/post.ts +++ b/src/state/models/post.ts @@ -58,12 +58,12 @@ export class PostModel implements RemoveIndex { // state transitions // = - private _xLoading() { + _xLoading() { this.isLoading = true this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.hasLoaded = true this.error = cleanError(err) @@ -75,12 +75,12 @@ export class PostModel implements RemoveIndex { // loader functions // = - private async _load() { + async _load() { this._xLoading() try { const urip = new AtUri(this.uri) - const res = await this.rootStore.api.app.bsky.feed.post.get({ - user: urip.host, + const res = await this.rootStore.agent.getPost({ + repo: urip.host, rkey: urip.rkey, }) // TODO @@ -94,7 +94,7 @@ export class PostModel implements RemoveIndex { } } - private _replaceAll(res: Post.Record) { + _replaceAll(res: Post.Record) { this.text = res.text this.entities = res.entities this.reply = res.reply diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts index 9d3eeff5..eacc6a29 100644 --- a/src/state/models/profile-view.ts +++ b/src/state/models/profile-view.ts @@ -2,15 +2,12 @@ import {makeAutoObservable, runInAction} from 'mobx' import {PickedMedia} from 'lib/media/picker' import { AppBskyActorGetProfile as GetProfile, - AppBskySystemDeclRef, - AppBskyActorUpdateProfile, + AppBskyActorProfile, + RichText, } from '@atproto/api' -type DeclRef = AppBskySystemDeclRef.Main -import {extractEntities} from 'lib/strings/rich-text-detection' import {RootStoreModel} from './root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' -import {RichText} from 'lib/strings/rich-text' export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' @@ -35,22 +32,18 @@ export class ProfileViewModel { // data did: string = '' handle: string = '' - declaration: DeclRef = { - cid: '', - actorType: '', - } creator: string = '' - displayName?: string - description?: string - avatar?: string - banner?: string + displayName?: string = '' + description?: string = '' + avatar?: string = '' + banner?: string = '' followersCount: number = 0 followsCount: number = 0 postsCount: number = 0 viewer = new ProfileViewViewerModel() // added data - descriptionRichText?: RichText + descriptionRichText?: RichText = new RichText({text: ''}) constructor( public rootStore: RootStoreModel, @@ -79,10 +72,6 @@ export class ProfileViewModel { return this.hasLoaded && !this.hasContent } - get isUser() { - return this.declaration.actorType === ACTOR_TYPE_USER - } - // public api // = @@ -111,18 +100,14 @@ export class ProfileViewModel { } if (followUri) { - await apilib.unfollow(this.rootStore, followUri) + await this.rootStore.agent.deleteFollow(followUri) runInAction(() => { this.followersCount-- this.viewer.following = undefined this.rootStore.me.follows.removeFollow(this.did) }) } else { - const res = await apilib.follow( - this.rootStore, - this.did, - this.declaration.cid, - ) + const res = await this.rootStore.agent.follow(this.did) runInAction(() => { this.followersCount++ this.viewer.following = res.uri @@ -132,49 +117,48 @@ export class ProfileViewModel { } async updateProfile( - updates: AppBskyActorUpdateProfile.InputSchema, + updates: AppBskyActorProfile.Record, newUserAvatar: PickedMedia | undefined | null, newUserBanner: PickedMedia | undefined | null, ) { - if (newUserAvatar) { - const res = await apilib.uploadBlob( - this.rootStore, - newUserAvatar.path, - newUserAvatar.mime, - ) - updates.avatar = { - cid: res.data.cid, - mimeType: newUserAvatar.mime, + await this.rootStore.agent.upsertProfile(async existing => { + existing = existing || {} + existing.displayName = updates.displayName + existing.description = updates.description + if (newUserAvatar) { + const res = await apilib.uploadBlob( + this.rootStore, + newUserAvatar.path, + newUserAvatar.mime, + ) + existing.avatar = res.data.blob + } else if (newUserAvatar === null) { + existing.avatar = undefined } - } else if (newUserAvatar === null) { - updates.avatar = null - } - if (newUserBanner) { - const res = await apilib.uploadBlob( - this.rootStore, - newUserBanner.path, - newUserBanner.mime, - ) - updates.banner = { - cid: res.data.cid, - mimeType: newUserBanner.mime, + if (newUserBanner) { + const res = await apilib.uploadBlob( + this.rootStore, + newUserBanner.path, + newUserBanner.mime, + ) + existing.banner = res.data.blob + } else if (newUserBanner === null) { + existing.banner = undefined } - } else if (newUserBanner === null) { - updates.banner = null - } - await this.rootStore.api.app.bsky.actor.updateProfile(updates) + return existing + }) await this.rootStore.me.load() await this.refresh() } async muteAccount() { - await this.rootStore.api.app.bsky.graph.mute({user: this.did}) + await this.rootStore.agent.mute(this.did) this.viewer.muted = true await this.refresh() } async unmuteAccount() { - await this.rootStore.api.app.bsky.graph.unmute({user: this.did}) + await this.rootStore.agent.unmute(this.did) this.viewer.muted = false await this.refresh() } @@ -182,13 +166,13 @@ export class ProfileViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -201,40 +185,40 @@ export class ProfileViewModel { // loader functions // = - private async _load(isRefreshing = false) { + async _load(isRefreshing = false) { this._xLoading(isRefreshing) try { - const res = await this.rootStore.api.app.bsky.actor.getProfile( - this.params, - ) + const res = await this.rootStore.agent.getProfile(this.params) this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation this._replaceAll(res) + await this._createRichText() this._xIdle() } catch (e: any) { this._xIdle(e) } } - private _replaceAll(res: GetProfile.Response) { + _replaceAll(res: GetProfile.Response) { this.did = res.data.did this.handle = res.data.handle - Object.assign(this.declaration, res.data.declaration) - this.creator = res.data.creator this.displayName = res.data.displayName this.description = res.data.description this.avatar = res.data.avatar this.banner = res.data.banner - this.followersCount = res.data.followersCount - this.followsCount = res.data.followsCount - this.postsCount = res.data.postsCount + this.followersCount = res.data.followersCount || 0 + this.followsCount = res.data.followsCount || 0 + this.postsCount = res.data.postsCount || 0 if (res.data.viewer) { Object.assign(this.viewer, res.data.viewer) this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following) } + } + + async _createRichText() { this.descriptionRichText = new RichText( - this.description || '', - extractEntities(this.description || ''), + {text: this.description || ''}, {cleanNewlines: true}, ) + await this.descriptionRichText.detectFacets(this.rootStore.agent) } } diff --git a/src/state/models/profiles-view.ts b/src/state/models/profiles-view.ts index 4241e50e..30e6d044 100644 --- a/src/state/models/profiles-view.ts +++ b/src/state/models/profiles-view.ts @@ -31,7 +31,7 @@ export class ProfilesViewModel { } } try { - const promise = this.rootStore.api.app.bsky.actor.getProfile({ + const promise = this.rootStore.agent.getProfile({ actor: did, }) this.cache.set(did, promise) diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts index 69a728d6..c9b089c7 100644 --- a/src/state/models/reposted-by-view.ts +++ b/src/state/models/reposted-by-view.ts @@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {AtUri} from '../../third-party/uri' import { AppBskyFeedGetRepostedBy as GetRepostedBy, - AppBskyActorRef as ActorRef, + AppBskyActorDefs, } from '@atproto/api' import {RootStoreModel} from './root-store' import {bundleAsync} from 'lib/async/bundle' @@ -11,7 +11,7 @@ import * as apilib from 'lib/api/index' const PAGE_SIZE = 30 -export type RepostedByItem = ActorRef.WithInfo +export type RepostedByItem = AppBskyActorDefs.ProfileViewBasic export class RepostedByViewModel { // state @@ -71,9 +71,9 @@ export class RepostedByViewModel { const params = Object.assign({}, this.params, { uri: this.resolvedUri, limit: PAGE_SIZE, - before: replace ? undefined : this.loadMoreCursor, + cursor: replace ? undefined : this.loadMoreCursor, }) - const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params) + const res = await this.rootStore.agent.getRepostedBy(params) if (replace) { this._replaceAll(res) } else { @@ -88,13 +88,13 @@ export class RepostedByViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -107,7 +107,7 @@ export class RepostedByViewModel { // helper functions // = - private async _resolveUri() { + async _resolveUri() { const urip = new AtUri(this.params.uri) if (!urip.host.startsWith('did:')) { try { @@ -121,12 +121,12 @@ export class RepostedByViewModel { }) } - private _replaceAll(res: GetRepostedBy.Response) { + _replaceAll(res: GetRepostedBy.Response) { this.repostedBy = [] this._appendAll(res) } - private _appendAll(res: GetRepostedBy.Response) { + _appendAll(res: GetRepostedBy.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.repostedBy = this.repostedBy.concat(res.data.repostedBy) diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index d8336d00..0c2a31d2 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -2,8 +2,8 @@ * The root store is the base of all modeled state. */ -import {makeAutoObservable, runInAction} from 'mobx' -import {AtpAgent} from '@atproto/api' +import {makeAutoObservable} from 'mobx' +import {BskyAgent} from '@atproto/api' import {createContext, useContext} from 'react' import {DeviceEventEmitter, EmitterSubscription} from 'react-native' import * as BgScheduler from 'lib/bg-scheduler' @@ -29,7 +29,7 @@ export const appInfo = z.object({ export type AppInfo = z.infer export class RootStoreModel { - agent: AtpAgent + agent: BskyAgent appInfo?: AppInfo log = new LogModel() session = new SessionModel(this) @@ -40,41 +40,16 @@ export class RootStoreModel { linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() - // HACK - // this flag is to track the lexicon breaking refactor - // it should be removed once we get that done - // -prf - hackUpgradeNeeded = false - async hackCheckIfUpgradeNeeded() { - try { - this.log.debug('hackCheckIfUpgradeNeeded()') - const res = await fetch('https://bsky.social/xrpc/app.bsky.feed.getLikes') - await res.text() - runInAction(() => { - this.hackUpgradeNeeded = res.status !== 501 - this.log.debug( - `hackCheckIfUpgradeNeeded() said ${this.hackUpgradeNeeded}`, - ) - }) - } catch (e) { - this.log.error('Failed to hackCheckIfUpgradeNeeded', {e}) - } - } - - constructor(agent: AtpAgent) { + constructor(agent: BskyAgent) { this.agent = agent makeAutoObservable(this, { - api: false, + agent: false, serialize: false, hydrate: false, }) this.initBgFetch() } - get api() { - return this.agent.api - } - setAppInfo(info: AppInfo) { this.appInfo = info } @@ -131,7 +106,7 @@ export class RootStoreModel { /** * Called by the session model. Refreshes session-oriented state. */ - async handleSessionChange(agent: AtpAgent) { + async handleSessionChange(agent: BskyAgent) { this.log.debug('RootStoreModel:handleSessionChange') this.agent = agent this.me.clear() @@ -259,7 +234,7 @@ export class RootStoreModel { async onBgFetch(taskId: string) { this.log.debug(`Background fetch fired for task ${taskId}`) if (this.session.hasSession) { - const res = await this.api.app.bsky.notification.getCount() + const res = await this.agent.countUnreadNotifications() const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count this.emitUnreadNotifications(res.data.count) this.log.debug( @@ -286,7 +261,7 @@ export class RootStoreModel { } const throwawayInst = new RootStoreModel( - new AtpAgent({service: 'http://localhost'}), + new BskyAgent({service: 'http://localhost'}), ) // this will be replaced by the loader, we just need to supply a value at init const RootStoreContext = createContext(throwawayInst) export const RootStoreProvider = RootStoreContext.Provider diff --git a/src/state/models/session.ts b/src/state/models/session.ts index e131b2b2..c2e10880 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -1,9 +1,9 @@ import {makeAutoObservable, runInAction} from 'mobx' import { - AtpAgent, + BskyAgent, AtpSessionEvent, AtpSessionData, - ComAtprotoServerGetAccountsConfig as GetAccountsConfig, + ComAtprotoServerDescribeServer as DescribeServer, } from '@atproto/api' import normalizeUrl from 'normalize-url' import {isObj, hasProp} from 'lib/type-guards' @@ -11,7 +11,7 @@ import {networkRetry} from 'lib/async/retry' import {z} from 'zod' import {RootStoreModel} from './root-store' -export type ServiceDescription = GetAccountsConfig.OutputSchema +export type ServiceDescription = DescribeServer.OutputSchema export const activeSession = z.object({ service: z.string(), @@ -40,7 +40,7 @@ export class SessionModel { // emergency log facility to help us track down this logout issue // remove when resolved // -prf - private _log(message: string, details?: Record) { + _log(message: string, details?: Record) { details = details || {} details.state = { data: this.data, @@ -73,6 +73,7 @@ export class SessionModel { rootStore: false, serialize: false, hydrate: false, + hasSession: false, }) } @@ -154,7 +155,7 @@ export class SessionModel { /** * Sets the active session */ - async setActiveSession(agent: AtpAgent, did: string) { + async setActiveSession(agent: BskyAgent, did: string) { this._log('SessionModel:setActiveSession') this.data = { service: agent.service.toString(), @@ -166,7 +167,7 @@ export class SessionModel { /** * Upserts a session into the accounts */ - private persistSession( + persistSession( service: string, did: string, event: AtpSessionEvent, @@ -225,7 +226,7 @@ export class SessionModel { /** * Clears any session tokens from the accounts; used on logout. */ - private clearSessionTokens() { + clearSessionTokens() { this._log('SessionModel:clearSessionTokens') this.accounts = this.accounts.map(acct => ({ service: acct.service, @@ -239,10 +240,8 @@ export class SessionModel { /** * Fetches additional information about an account on load. */ - private async loadAccountInfo(agent: AtpAgent, did: string) { - const res = await agent.api.app.bsky.actor - .getProfile({actor: did}) - .catch(_e => undefined) + async loadAccountInfo(agent: BskyAgent, did: string) { + const res = await agent.getProfile({actor: did}).catch(_e => undefined) if (res) { return { dispayName: res.data.displayName, @@ -255,8 +254,8 @@ export class SessionModel { * Helper to fetch the accounts config settings from an account. */ async describeService(service: string): Promise { - const agent = new AtpAgent({service}) - const res = await agent.api.com.atproto.server.getAccountsConfig({}) + const agent = new BskyAgent({service}) + const res = await agent.com.atproto.server.describeServer({}) return res.data } @@ -272,7 +271,7 @@ export class SessionModel { return false } - const agent = new AtpAgent({ + const agent = new BskyAgent({ service: account.service, persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => { this.persistSession(account.service, account.did, evt, sess) @@ -321,7 +320,7 @@ export class SessionModel { password: string }) { this._log('SessionModel:login') - const agent = new AtpAgent({service}) + const agent = new BskyAgent({service}) await agent.login({identifier, password}) if (!agent.session) { throw new Error('Failed to establish session') @@ -355,7 +354,7 @@ export class SessionModel { inviteCode?: string }) { this._log('SessionModel:createAccount') - const agent = new AtpAgent({service}) + const agent = new BskyAgent({service}) await agent.createAccount({ handle, password, @@ -389,7 +388,7 @@ export class SessionModel { // need to evaluate why deleting the session has caused errors at times // -prf /*if (this.hasSession) { - this.rootStore.api.com.atproto.session.delete().catch((e: any) => { + this.rootStore.agent.com.atproto.session.delete().catch((e: any) => { this.rootStore.log.warn( '(Minor issue) Failed to delete session on the server', e, @@ -415,7 +414,7 @@ export class SessionModel { if (!sess) { return } - const res = await this.rootStore.api.app.bsky.actor + const res = await this.rootStore.agent .getProfile({actor: sess.did}) .catch(_e => undefined) if (res?.success) { diff --git a/src/state/models/suggested-posts-view.ts b/src/state/models/suggested-posts-view.ts index 7a5ca81b..46bf235f 100644 --- a/src/state/models/suggested-posts-view.ts +++ b/src/state/models/suggested-posts-view.ts @@ -72,12 +72,12 @@ export class SuggestedPostsView { // state transitions // = - private _xLoading() { + _xLoading() { this.isLoading = true this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.hasLoaded = true this.error = cleanError(err) diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts index a212fe05..e661cb59 100644 --- a/src/state/models/ui/create-account.ts +++ b/src/state/models/ui/create-account.ts @@ -2,7 +2,7 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {ServiceDescription} from '../session' import {DEFAULT_SERVICE} from 'state/index' -import {ComAtprotoAccountCreate} from '@atproto/api' +import {ComAtprotoServerCreateAccount} from '@atproto/api' import * as EmailValidator from 'email-validator' import {createFullHandle} from 'lib/strings/handles' import {cleanError} from 'lib/strings/errors' @@ -99,7 +99,7 @@ export class CreateAccountModel { }) } catch (e: any) { let errMsg = e.toString() - if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { errMsg = 'Invite code not accepted. Check that you input it correctly and try again.' } diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 280541b7..59529aa3 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -40,7 +40,7 @@ export class ProfileUiModel { ) this.profile = new ProfileViewModel(rootStore, {actor: params.user}) this.feed = new FeedModel(rootStore, 'author', { - author: params.user, + actor: params.user, limit: 10, }) } @@ -64,16 +64,8 @@ export class ProfileUiModel { return this.profile.isRefreshing || this.currentView.isRefreshing } - get isUser() { - return this.profile.isUser - } - get selectorItems() { - if (this.isUser) { - return USER_SELECTOR_ITEMS - } else { - return USER_SELECTOR_ITEMS - } + return USER_SELECTOR_ITEMS } get selectedView() { diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts index 91e1b24b..8436b098 100644 --- a/src/state/models/ui/search.ts +++ b/src/state/models/ui/search.ts @@ -1,6 +1,6 @@ import {makeAutoObservable, runInAction} from 'mobx' import {searchProfiles, searchPosts} from 'lib/api/search' -import {AppBskyActorProfile as Profile} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' export class SearchUIModel { @@ -8,7 +8,7 @@ export class SearchUIModel { isProfilesLoading = false query: string = '' postUris: string[] = [] - profiles: Profile.View[] = [] + profiles: AppBskyActorDefs.ProfileView[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable(this) @@ -34,10 +34,10 @@ export class SearchUIModel { this.isPostsLoading = false }) - let profiles: Profile.View[] = [] + let profiles: AppBskyActorDefs.ProfileView[] = [] if (profilesSearch?.length) { do { - const res = await this.rootStore.api.app.bsky.actor.getProfiles({ + const res = await this.rootStore.agent.getProfiles({ actors: profilesSearch.splice(0, 25).map(p => p.did), }) profiles = profiles.concat(res.data.profiles) diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index fec1e289..7f57d5b5 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,3 +1,4 @@ +import {AppBskyEmbedRecord} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable} from 'mobx' import {ProfileViewModel} from '../profile-view' @@ -111,6 +112,7 @@ export interface ComposerOptsQuote { displayName?: string avatar?: string } + embeds?: AppBskyEmbedRecord.ViewRecord['embeds'] } export interface ComposerOpts { replyTo?: ComposerOptsPostRef diff --git a/src/state/models/user-autocomplete-view.ts b/src/state/models/user-autocomplete-view.ts index 8e4211c2..ad89bb08 100644 --- a/src/state/models/user-autocomplete-view.ts +++ b/src/state/models/user-autocomplete-view.ts @@ -1,5 +1,5 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorRef} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import AwaitLock from 'await-lock' import {RootStoreModel} from './root-store' @@ -11,8 +11,8 @@ export class UserAutocompleteViewModel { lock = new AwaitLock() // data - follows: AppBskyActorRef.WithInfo[] = [] - searchRes: AppBskyActorRef.WithInfo[] = [] + follows: AppBskyActorDefs.ProfileViewBasic[] = [] + searchRes: AppBskyActorDefs.ProfileViewBasic[] = [] knownHandles: Set = new Set() constructor(public rootStore: RootStoreModel) { @@ -76,9 +76,9 @@ export class UserAutocompleteViewModel { // internal // = - private async _getFollows() { - const res = await this.rootStore.api.app.bsky.graph.getFollows({ - user: this.rootStore.me.did || '', + async _getFollows() { + const res = await this.rootStore.agent.getFollows({ + actor: this.rootStore.me.did || '', }) runInAction(() => { this.follows = res.data.follows @@ -88,13 +88,13 @@ export class UserAutocompleteViewModel { }) } - private async _search() { - const res = await this.rootStore.api.app.bsky.actor.searchTypeahead({ + async _search() { + const res = await this.rootStore.agent.searchActorsTypeahead({ term: this.prefix, limit: 8, }) runInAction(() => { - this.searchRes = res.data.users + this.searchRes = res.data.actors for (const u of this.searchRes) { this.knownHandles.add(u.handle) } diff --git a/src/state/models/user-followers-view.ts b/src/state/models/user-followers-view.ts index 7400262a..055032eb 100644 --- a/src/state/models/user-followers-view.ts +++ b/src/state/models/user-followers-view.ts @@ -1,7 +1,7 @@ import {makeAutoObservable} from 'mobx' import { AppBskyGraphGetFollowers as GetFollowers, - AppBskyActorRef as ActorRef, + AppBskyActorDefs as ActorDefs, } from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from 'lib/strings/errors' @@ -9,7 +9,7 @@ import {bundleAsync} from 'lib/async/bundle' const PAGE_SIZE = 30 -export type FollowerItem = ActorRef.WithInfo +export type FollowerItem = ActorDefs.ProfileViewBasic export class UserFollowersViewModel { // state @@ -22,10 +22,9 @@ export class UserFollowersViewModel { loadMoreCursor?: string // data - subject: ActorRef.WithInfo = { + subject: ActorDefs.ProfileViewBasic = { did: '', handle: '', - declaration: {cid: '', actorType: ''}, } followers: FollowerItem[] = [] @@ -71,9 +70,9 @@ export class UserFollowersViewModel { try { const params = Object.assign({}, this.params, { limit: PAGE_SIZE, - before: replace ? undefined : this.loadMoreCursor, + cursor: replace ? undefined : this.loadMoreCursor, }) - const res = await this.rootStore.api.app.bsky.graph.getFollowers(params) + const res = await this.rootStore.agent.getFollowers(params) if (replace) { this._replaceAll(res) } else { @@ -88,13 +87,13 @@ export class UserFollowersViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -107,12 +106,12 @@ export class UserFollowersViewModel { // helper functions // = - private _replaceAll(res: GetFollowers.Response) { + _replaceAll(res: GetFollowers.Response) { this.followers = [] this._appendAll(res) } - private _appendAll(res: GetFollowers.Response) { + _appendAll(res: GetFollowers.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.followers = this.followers.concat(res.data.followers) diff --git a/src/state/models/user-follows-view.ts b/src/state/models/user-follows-view.ts index 7d28d7eb..6d9d8459 100644 --- a/src/state/models/user-follows-view.ts +++ b/src/state/models/user-follows-view.ts @@ -1,7 +1,7 @@ import {makeAutoObservable} from 'mobx' import { AppBskyGraphGetFollows as GetFollows, - AppBskyActorRef as ActorRef, + AppBskyActorDefs as ActorDefs, } from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from 'lib/strings/errors' @@ -9,7 +9,7 @@ import {bundleAsync} from 'lib/async/bundle' const PAGE_SIZE = 30 -export type FollowItem = ActorRef.WithInfo +export type FollowItem = ActorDefs.ProfileViewBasic export class UserFollowsViewModel { // state @@ -22,10 +22,9 @@ export class UserFollowsViewModel { loadMoreCursor?: string // data - subject: ActorRef.WithInfo = { + subject: ActorDefs.ProfileViewBasic = { did: '', handle: '', - declaration: {cid: '', actorType: ''}, } follows: FollowItem[] = [] @@ -71,9 +70,9 @@ export class UserFollowsViewModel { try { const params = Object.assign({}, this.params, { limit: PAGE_SIZE, - before: replace ? undefined : this.loadMoreCursor, + cursor: replace ? undefined : this.loadMoreCursor, }) - const res = await this.rootStore.api.app.bsky.graph.getFollows(params) + const res = await this.rootStore.agent.getFollows(params) if (replace) { this._replaceAll(res) } else { @@ -88,13 +87,13 @@ export class UserFollowsViewModel { // state transitions // = - private _xLoading(isRefreshing = false) { + _xLoading(isRefreshing = false) { this.isLoading = true this.isRefreshing = isRefreshing this.error = '' } - private _xIdle(err?: any) { + _xIdle(err?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true @@ -107,12 +106,12 @@ export class UserFollowsViewModel { // helper functions // = - private _replaceAll(res: GetFollows.Response) { + _replaceAll(res: GetFollows.Response) { this.follows = [] this._appendAll(res) } - private _appendAll(res: GetFollows.Response) { + _appendAll(res: GetFollows.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.follows = this.follows.concat(res.data.follows) diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 618c15cf..6ece903d 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -75,16 +75,14 @@ export const CreateAccount = observer( {model.step === 3 && } - + Back {model.canNext ? ( - + {model.isProcessing ? ( ) : ( @@ -95,7 +93,7 @@ export const CreateAccount = observer( ) : model.didServiceDescriptionFetchFail ? ( Retry diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 0a628f9d..ca964ede 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -60,12 +60,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { This is the company that keeps you online.