Lex refactor (#362)
* Remove the hackcheck for upgrades * Rename the PostEmbeds folder to match the codebase style * Updates to latest lex refactor * Update to use new bsky agent * Update to use api package's richtext library * Switch to upsertProfile * Add TextEncoder/TextDecoder polyfill * Add Intl.Segmenter polyfill * Update composer to calculate lengths by grapheme * Fix detox * Fix login in e2e * Create account e2e passing * Implement an e2e mocking framework * Don't use private methods on mobx models as mobx can't track them * Add tooling for e2e-specific builds and add e2e media-picker mock * Add some tests and fix some bugs around profile editing * Add shell tests * Add home screen tests * Add thread screen tests * Add tests for other user profile screens * Add search screen tests * Implement profile imagery change tools and tests * Update to new embed behaviors * Add post tests * Fix to profile-screen test * Fix session resumption * Update web composer to new api * 1.11.0 * Fix pagination cursor parameters * Add quote posts to notifications * Fix embed layouts * Remove youtube inline player and improve tap handling on link cards * Reset minimal shell mode on all screen loads and feed swipes (close #299) * Update podfile.lock * Improve post notfound UI (close #366) * Bump atproto packageszio/stable
parent
19f3a2fa92
commit
a3334a01a2
11
.detoxrc.js
11
.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',
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/** @type {import('@jest/types').Config.InitialOptions} */
|
||||
module.exports = {
|
||||
rootDir: '..',
|
||||
testMatch: ['<rootDir>/e2e/**/*.test.js'],
|
||||
testMatch: ['<rootDir>/__e2e__/**/*.test.ts'],
|
||||
testTimeout: 120000,
|
||||
maxWorkers: 1,
|
||||
globalSetup: 'detox/runners/jest/globalSetup',
|
|
@ -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()
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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))
|
|
@ -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 = [
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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')
|
||||
})
|
||||
})
|
2
app.json
2
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",
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
466
ios/Podfile.lock
466
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
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.10</string>
|
||||
<string>1.11</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
|
257
jest/test-pds.ts
257
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<void>
|
||||
}
|
||||
|
||||
// NOTE
|
||||
// deterministic date generator
|
||||
// we use this to ensure the mock dataset is always the same
|
||||
// which is very useful when testing
|
||||
function* dateGen() {
|
||||
let start = 1657846031914
|
||||
while (true) {
|
||||
yield new Date(start).toISOString()
|
||||
start += 1e3
|
||||
}
|
||||
}
|
||||
|
||||
export async function createServer(): Promise<TestPDS> {
|
||||
const keypair = await crypto.EcdsaKeypair.create()
|
||||
const repoSigningKey = await crypto.Secp256k1Keypair.create()
|
||||
const plcRotationKey = await crypto.Secp256k1Keypair.create()
|
||||
const port = await getPort()
|
||||
|
||||
// run plc server
|
||||
const plcDb = plc.Database.memory()
|
||||
await plcDb.migrateToLatestOrThrow()
|
||||
const plcServer = plc.PlcServer.create({db: plcDb})
|
||||
const plcDb = PlcDatabase.mock()
|
||||
|
||||
const plcServer = PlcServer.create({db: plcDb})
|
||||
const plcListener = await plcServer.start()
|
||||
const plcPort = (plcListener.address() as AddressInfo).port
|
||||
const plcUrl = `http://localhost:${plcPort}`
|
||||
|
||||
const recoveryKey = (await crypto.EcdsaKeypair.create()).did()
|
||||
const recoveryKey = (await crypto.Secp256k1Keypair.create()).did()
|
||||
|
||||
const plcClient = new plc.PlcClient(plcUrl)
|
||||
const serverDid = await plcClient.createDid(
|
||||
keypair,
|
||||
recoveryKey,
|
||||
'localhost',
|
||||
'https://pds.public.url',
|
||||
)
|
||||
const plcClient = new plc.Client(plcUrl)
|
||||
const serverDid = await plcClient.createDid({
|
||||
signingKey: repoSigningKey.did(),
|
||||
rotationKeys: [recoveryKey, plcRotationKey.did()],
|
||||
handle: 'localhost',
|
||||
pds: `http://localhost:${port}`,
|
||||
signer: plcRotationKey,
|
||||
})
|
||||
|
||||
const blobstoreLoc = path.join(os.tmpdir(), crypto.randomStr(5, 'base32'))
|
||||
|
||||
const cfg = new PDSServerConfig({
|
||||
const cfg = new ServerConfig({
|
||||
debugMode: true,
|
||||
version: '0.0.0',
|
||||
scheme: 'http',
|
||||
hostname: 'localhost',
|
||||
port,
|
||||
serverDid,
|
||||
recoveryKey,
|
||||
adminPassword: 'admin-pass',
|
||||
adminPassword: ADMIN_PASSWORD,
|
||||
inviteRequired: false,
|
||||
didPlcUrl: plcUrl,
|
||||
jwtSecret: 'jwt-secret',
|
||||
availableUserDomains: ['.test'],
|
||||
appUrlPasswordReset: 'app://forgot-password',
|
||||
emailNoReplyAddress: 'noreply@blueskyweb.xyz',
|
||||
publicUrl: 'https://pds.public.url',
|
||||
publicUrl: `http://localhost:${port}`,
|
||||
imgUriSalt: '9dd04221f5755bce5f55f47464c27e1e',
|
||||
imgUriKey:
|
||||
'f23ecd142835025f42c3db2cf25dd813956c178392760256211f9d315f8ab4d8',
|
||||
|
@ -88,22 +75,33 @@ export async function createServer(): Promise<TestPDS> {
|
|||
blobstoreLocation: `${blobstoreLoc}/blobs`,
|
||||
blobstoreTmp: `${blobstoreLoc}/tmp`,
|
||||
maxSubscriptionBuffer: 200,
|
||||
repoBackfillLimitMs: 1e3 * 60 * 60,
|
||||
repoBackfillLimitMs: HOUR,
|
||||
})
|
||||
|
||||
const db = PDSDatabase.memory()
|
||||
const db =
|
||||
cfg.dbPostgresUrl !== undefined
|
||||
? Database.postgres({
|
||||
url: cfg.dbPostgresUrl,
|
||||
schema: cfg.dbPostgresSchema,
|
||||
})
|
||||
: Database.memory()
|
||||
await db.migrateToLatestOrThrow()
|
||||
|
||||
const blobstore = new MemoryBlobStore()
|
||||
|
||||
const pds = PDSServer.create({db, blobstore, keypair, config: cfg})
|
||||
const pdsServer = await pds.start()
|
||||
const pdsPort = (pdsServer.address() as AddressInfo).port
|
||||
const pdsUrl = `http://localhost:${pdsPort}`
|
||||
const testUsers = await genMockData(pdsUrl)
|
||||
const pds = PDS.create({
|
||||
db,
|
||||
blobstore,
|
||||
repoSigningKey,
|
||||
plcRotationKey,
|
||||
config: cfg,
|
||||
})
|
||||
await pds.start()
|
||||
const pdsUrl = `http://localhost:${port}`
|
||||
|
||||
return {
|
||||
pdsUrl,
|
||||
users: testUsers,
|
||||
mocker: new Mocker(pdsUrl),
|
||||
async close() {
|
||||
await pds.destroy()
|
||||
await plcServer.destroy()
|
||||
|
@ -111,90 +109,93 @@ export async function createServer(): Promise<TestPDS> {
|
|||
}
|
||||
}
|
||||
|
||||
async function genMockData(pdsUrl: string): Promise<TestUsers> {
|
||||
const date = dateGen()
|
||||
class Mocker {
|
||||
agent: BskyAgent
|
||||
users: Record<string, TestUser> = {}
|
||||
|
||||
const agents = {
|
||||
loggedout: new AtpAgent({service: pdsUrl}),
|
||||
alice: new AtpAgent({service: pdsUrl}),
|
||||
bob: new AtpAgent({service: pdsUrl}),
|
||||
carla: new AtpAgent({service: pdsUrl}),
|
||||
constructor(public service: string) {
|
||||
this.agent = new BskyAgent({service})
|
||||
}
|
||||
const users: TestUser[] = [
|
||||
{
|
||||
email: 'alice@test.com',
|
||||
did: '',
|
||||
declarationCid: '',
|
||||
handle: 'alice.test',
|
||||
password: 'hunter2',
|
||||
agent: agents.alice,
|
||||
},
|
||||
{
|
||||
email: 'bob@test.com',
|
||||
did: '',
|
||||
declarationCid: '',
|
||||
handle: 'bob.test',
|
||||
password: 'hunter2',
|
||||
agent: agents.bob,
|
||||
},
|
||||
{
|
||||
email: 'carla@test.com',
|
||||
did: '',
|
||||
declarationCid: '',
|
||||
handle: 'carla.test',
|
||||
password: 'hunter2',
|
||||
agent: agents.carla,
|
||||
},
|
||||
]
|
||||
const alice = users[0]
|
||||
const bob = users[1]
|
||||
const carla = users[2]
|
||||
|
||||
let _i = 1
|
||||
for (const user of users) {
|
||||
const res = await agents.loggedout.api.com.atproto.account.create({
|
||||
email: user.email,
|
||||
handle: user.handle,
|
||||
password: user.password,
|
||||
// NOTE
|
||||
// deterministic date generator
|
||||
// we use this to ensure the mock dataset is always the same
|
||||
// which is very useful when testing
|
||||
*dateGen() {
|
||||
let start = 1657846031914
|
||||
while (true) {
|
||||
yield new Date(start).toISOString()
|
||||
start += 1e3
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(name: string) {
|
||||
const agent = new BskyAgent({service: this.agent.service})
|
||||
const email = `fake${Object.keys(this.users).length + 1}@fake.com`
|
||||
const res = await agent.createAccount({
|
||||
email,
|
||||
handle: name + '.test',
|
||||
password: 'hunter2',
|
||||
})
|
||||
user.agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
||||
const {data: profile} = await user.agent.api.app.bsky.actor.getProfile({
|
||||
actor: user.handle,
|
||||
})
|
||||
user.did = res.data.did
|
||||
user.declarationCid = profile.declaration.cid
|
||||
await user.agent.api.app.bsky.actor.profile.create(
|
||||
{did: user.did},
|
||||
{
|
||||
displayName: ucfirst(user.handle).slice(0, -5),
|
||||
description: `Test user ${_i++}`,
|
||||
},
|
||||
)
|
||||
this.users[name] = {
|
||||
did: res.data.did,
|
||||
email,
|
||||
handle: name + '.test',
|
||||
password: 'hunter2',
|
||||
agent: agent,
|
||||
}
|
||||
}
|
||||
|
||||
// everybody follows everybody
|
||||
const follow = async (author: TestUser, subject: TestUser) => {
|
||||
await author.agent.api.app.bsky.graph.follow.create(
|
||||
{did: author.did},
|
||||
{
|
||||
subject: {
|
||||
did: subject.did,
|
||||
declarationCid: subject.declarationCid,
|
||||
},
|
||||
createdAt: date.next().value || '',
|
||||
},
|
||||
)
|
||||
async follow(a: string, b: string) {
|
||||
await this.users[a].agent.follow(this.users[b].did)
|
||||
}
|
||||
await follow(alice, bob)
|
||||
await follow(alice, carla)
|
||||
await follow(bob, alice)
|
||||
await follow(bob, carla)
|
||||
await follow(carla, alice)
|
||||
await follow(carla, bob)
|
||||
|
||||
return {alice, bob, carla}
|
||||
async generateStandardGraph() {
|
||||
await this.createUser('alice')
|
||||
await this.createUser('bob')
|
||||
await this.createUser('carla')
|
||||
|
||||
await this.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
|
||||
await this.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
|
||||
await this.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
|
||||
await this.follow('alice', 'bob')
|
||||
await this.follow('alice', 'carla')
|
||||
await this.follow('bob', 'alice')
|
||||
await this.follow('bob', 'carla')
|
||||
await this.follow('carla', 'alice')
|
||||
await this.follow('carla', 'bob')
|
||||
}
|
||||
}
|
||||
|
||||
function ucfirst(str: string): string {
|
||||
return str.at(0)?.toUpperCase() + str.slice(1)
|
||||
const checkAvailablePort = (port: number) =>
|
||||
new Promise(resolve => {
|
||||
const server = net.createServer()
|
||||
server.unref()
|
||||
server.on('error', () => resolve(false))
|
||||
server.listen({port}, () => {
|
||||
server.close(() => {
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function getPort() {
|
||||
for (let i = 3000; i < 65000; i++) {
|
||||
if (await checkAvailablePort(i)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
throw new Error('Unable to find an available port')
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
26
package.json
26
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",
|
||||
|
|
|
@ -29,7 +29,6 @@ const App = observer(() => {
|
|||
analytics.init(store)
|
||||
notifee.init(store)
|
||||
SplashScreen.hide()
|
||||
store.hackCheckIfUpgradeNeeded()
|
||||
Linking.getInitialURL().then((url: string | null) => {
|
||||
if (url) {
|
||||
handleLink(url)
|
||||
|
|
|
@ -31,7 +31,7 @@ import {ProfileScreen} from './view/screens/Profile'
|
|||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PostUpvotedByScreen} from './view/screens/PostUpvotedBy'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
import {DebugScreen} from './view/screens/Debug'
|
||||
import {LogScreen} from './view/screens/Log'
|
||||
|
@ -62,7 +62,7 @@ function commonScreens(Stack: typeof HomeTab) {
|
|||
/>
|
||||
<Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} />
|
||||
<Stack.Screen name="PostThread" component={PostThreadScreen} />
|
||||
<Stack.Screen name="PostUpvotedBy" component={PostUpvotedByScreen} />
|
||||
<Stack.Screen name="PostLikedBy" component={PostLikedByScreen} />
|
||||
<Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} />
|
||||
<Stack.Screen name="Debug" component={DebugScreen} />
|
||||
<Stack.Screen name="Log" component={LogScreen} />
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import AtpAgent from '@atproto/api'
|
||||
import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api'
|
||||
import RNFS from 'react-native-fs'
|
||||
|
||||
const GET_TIMEOUT = 15e3 // 15s
|
||||
const POST_TIMEOUT = 60e3 // 60s
|
||||
|
||||
export function doPolyfill() {
|
||||
AtpAgent.configure({fetch: fetchHandler})
|
||||
BskyAgent.configure({fetch: fetchHandler})
|
||||
}
|
||||
|
||||
interface FetchHandlerResponse {
|
||||
|
@ -22,7 +22,7 @@ async function fetchHandler(
|
|||
): Promise<FetchHandlerResponse> {
|
||||
const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type']
|
||||
if (reqMimeType && reqMimeType.startsWith('application/json')) {
|
||||
reqBody = JSON.stringify(reqBody)
|
||||
reqBody = stringifyLex(reqBody)
|
||||
} else if (
|
||||
typeof reqBody === 'string' &&
|
||||
(reqBody.startsWith('/') || reqBody.startsWith('file:'))
|
||||
|
@ -65,7 +65,7 @@ async function fetchHandler(
|
|||
let resBody
|
||||
if (resMimeType) {
|
||||
if (resMimeType.startsWith('application/json')) {
|
||||
resBody = await res.json()
|
||||
resBody = jsonToLex(await res.json())
|
||||
} else if (resMimeType.startsWith('text/')) {
|
||||
resBody = await res.text()
|
||||
} else {
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
export function doPolyfill() {
|
||||
// TODO needed? native fetch may work fine -prf
|
||||
// AtpApi.xrpc.fetch = fetchHandler
|
||||
// no polyfill is needed on web
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import {RootStoreModel} from 'state/index'
|
||||
import {
|
||||
AppBskyFeedFeedViewPost,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
} from '@atproto/api'
|
||||
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
|
||||
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
|
||||
|
||||
async function getMultipleAuthorsPosts(
|
||||
rootStore: RootStoreModel,
|
||||
|
@ -12,12 +12,12 @@ async function getMultipleAuthorsPosts(
|
|||
limit: number = 10,
|
||||
) {
|
||||
const responses = await Promise.all(
|
||||
authors.map((author, index) =>
|
||||
rootStore.api.app.bsky.feed
|
||||
authors.map((actor, index) =>
|
||||
rootStore.agent
|
||||
.getAuthorFeed({
|
||||
author,
|
||||
actor,
|
||||
limit,
|
||||
before: cursor ? cursor.split(',')[index] : undefined,
|
||||
cursor: cursor ? cursor.split(',')[index] : undefined,
|
||||
})
|
||||
.catch(_err => ({success: false, headers: {}, data: {feed: []}})),
|
||||
),
|
||||
|
@ -29,14 +29,14 @@ function mergePosts(
|
|||
responses: GetAuthorFeed.Response[],
|
||||
{repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean},
|
||||
) {
|
||||
let posts: AppBskyFeedFeedViewPost.Main[] = []
|
||||
let posts: AppBskyFeedDefs.FeedViewPost[] = []
|
||||
|
||||
if (bestOfOnly) {
|
||||
for (const res of responses) {
|
||||
if (res.success) {
|
||||
// filter the feed down to the post with the most upvotes
|
||||
// filter the feed down to the post with the most likes
|
||||
res.data.feed = res.data.feed.reduce(
|
||||
(acc: AppBskyFeedFeedViewPost.Main[], v) => {
|
||||
(acc: AppBskyFeedDefs.FeedViewPost[], v) => {
|
||||
if (
|
||||
!acc?.[0] &&
|
||||
!v.reason &&
|
||||
|
@ -49,7 +49,7 @@ function mergePosts(
|
|||
acc &&
|
||||
!v.reason &&
|
||||
!v.reply &&
|
||||
v.post.upvoteCount > acc[0]?.post.upvoteCount &&
|
||||
(v.post.likeCount || 0) > (acc[0]?.post.likeCount || 0) &&
|
||||
isRecentEnough(v.post.indexedAt)
|
||||
) {
|
||||
return [v]
|
||||
|
@ -92,7 +92,7 @@ function mergePosts(
|
|||
return posts
|
||||
}
|
||||
|
||||
function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean {
|
||||
function isARepostOfSomeoneElse(post: AppBskyFeedDefs.FeedViewPost): boolean {
|
||||
return (
|
||||
post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' &&
|
||||
post.post.author.did !== (post.reason as ReasonRepost).by.did
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import {AppBskyFeedFeedViewPost} from '@atproto/api'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import lande from 'lande'
|
||||
type FeedViewPost = AppBskyFeedFeedViewPost.Main
|
||||
import {hasProp} from '@atproto/lexicon'
|
||||
import {hasProp} from 'lib/type-guards'
|
||||
import {LANGUAGES_MAP_CODE2} from '../../locale/languages'
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
|
||||
export type FeedTunerFn = (
|
||||
tuner: FeedTuner,
|
||||
|
@ -174,7 +174,7 @@ export class FeedTuner {
|
|||
}
|
||||
const item = slices[i].rootItem
|
||||
const isRepost = Boolean(item.reason)
|
||||
if (!isRepost && item.post.upvoteCount < 2) {
|
||||
if (!isRepost && (item.post.likeCount || 0) < 2) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedExternal,
|
||||
ComAtprotoBlobUpload,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
ComAtprotoRepoUploadBlob,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {extractEntities} from 'lib/strings/rich-text-detection'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {Image} from '../media/manip'
|
||||
import {RichText} from '../strings/rich-text'
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
export interface ExternalEmbedDraft {
|
||||
|
@ -27,7 +27,7 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
|
|||
if (didOrHandle.startsWith('did:')) {
|
||||
return didOrHandle
|
||||
}
|
||||
const res = await store.api.com.atproto.handle.resolve({
|
||||
const res = await store.agent.resolveHandle({
|
||||
handle: didOrHandle,
|
||||
})
|
||||
return res.data.did
|
||||
|
@ -37,15 +37,15 @@ export async function uploadBlob(
|
|||
store: RootStoreModel,
|
||||
blob: string,
|
||||
encoding: string,
|
||||
): Promise<ComAtprotoBlobUpload.Response> {
|
||||
): Promise<ComAtprotoRepoUploadBlob.Response> {
|
||||
if (isWeb) {
|
||||
// `blob` should be a data uri
|
||||
return store.api.com.atproto.blob.upload(convertDataURIToUint8Array(blob), {
|
||||
return store.agent.uploadBlob(convertDataURIToUint8Array(blob), {
|
||||
encoding,
|
||||
})
|
||||
} else {
|
||||
// `blob` should be a path to a file in the local FS
|
||||
return store.api.com.atproto.blob.upload(
|
||||
return store.agent.uploadBlob(
|
||||
blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
|
||||
{encoding},
|
||||
)
|
||||
|
@ -70,22 +70,18 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
|||
| AppBskyEmbedImages.Main
|
||||
| AppBskyEmbedExternal.Main
|
||||
| AppBskyEmbedRecord.Main
|
||||
| AppBskyEmbedRecordWithMedia.Main
|
||||
| undefined
|
||||
let reply
|
||||
const text = new RichText(opts.rawText, undefined, {
|
||||
const rt = new RichText(
|
||||
{text: opts.rawText.trim()},
|
||||
{
|
||||
cleanNewlines: true,
|
||||
}).text.trim()
|
||||
},
|
||||
)
|
||||
|
||||
opts.onStateChange?.('Processing...')
|
||||
const entities = extractEntities(text, opts.knownHandles)
|
||||
if (entities) {
|
||||
for (const ent of entities) {
|
||||
if (ent.type === 'mention') {
|
||||
const prof = await store.profiles.getProfile(ent.value)
|
||||
ent.value = prof.data.did
|
||||
}
|
||||
}
|
||||
}
|
||||
await rt.detectFacets(store.agent)
|
||||
|
||||
if (opts.quote) {
|
||||
embed = {
|
||||
|
@ -95,24 +91,37 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
|||
cid: opts.quote.cid,
|
||||
},
|
||||
} as AppBskyEmbedRecord.Main
|
||||
} else if (opts.images?.length) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.images',
|
||||
images: [],
|
||||
} as AppBskyEmbedImages.Main
|
||||
let i = 1
|
||||
}
|
||||
|
||||
if (opts.images?.length) {
|
||||
const images: AppBskyEmbedImages.Image[] = []
|
||||
for (const image of opts.images) {
|
||||
opts.onStateChange?.(`Uploading image #${i++}...`)
|
||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||
const res = await uploadBlob(store, image, 'image/jpeg')
|
||||
embed.images.push({
|
||||
image: {
|
||||
cid: res.data.cid,
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
alt: '', // TODO supply alt text
|
||||
})
|
||||
}
|
||||
} else if (opts.extLink) {
|
||||
|
||||
if (opts.quote) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.recordWithMedia',
|
||||
record: embed,
|
||||
media: {
|
||||
$type: 'app.bsky.embed.images',
|
||||
images,
|
||||
},
|
||||
} as AppBskyEmbedRecordWithMedia.Main
|
||||
} else {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.images',
|
||||
images,
|
||||
} as AppBskyEmbedImages.Main
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.extLink && !opts.images?.length) {
|
||||
let thumb
|
||||
if (opts.extLink.localThumb) {
|
||||
opts.onStateChange?.('Uploading link thumbnail...')
|
||||
|
@ -138,12 +147,25 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
|||
opts.extLink.localThumb.path,
|
||||
encoding,
|
||||
)
|
||||
thumb = {
|
||||
cid: thumbUploadRes.data.cid,
|
||||
mimeType: encoding,
|
||||
}
|
||||
thumb = thumbUploadRes.data.blob
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.quote) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.recordWithMedia',
|
||||
record: embed,
|
||||
media: {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
uri: opts.extLink.uri,
|
||||
title: opts.extLink.meta?.title || '',
|
||||
description: opts.extLink.meta?.description || '',
|
||||
thumb,
|
||||
},
|
||||
} as AppBskyEmbedExternal.Main,
|
||||
} as AppBskyEmbedRecordWithMedia.Main
|
||||
} else {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
|
@ -154,11 +176,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
|||
},
|
||||
} as AppBskyEmbedExternal.Main
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.replyTo) {
|
||||
const replyToUrip = new AtUri(opts.replyTo)
|
||||
const parentPost = await store.api.app.bsky.feed.post.get({
|
||||
user: replyToUrip.host,
|
||||
const parentPost = await store.agent.getPost({
|
||||
repo: replyToUrip.host,
|
||||
rkey: replyToUrip.rkey,
|
||||
})
|
||||
if (parentPost) {
|
||||
|
@ -175,16 +198,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
|||
|
||||
try {
|
||||
opts.onStateChange?.('Posting...')
|
||||
return await store.api.app.bsky.feed.post.create(
|
||||
{did: store.me.did || ''},
|
||||
{
|
||||
text,
|
||||
return await store.agent.post({
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
reply,
|
||||
embed,
|
||||
entities,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to create post: ${e.toString()}`)
|
||||
if (isNetworkError(e)) {
|
||||
|
@ -197,49 +216,6 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function repost(store: RootStoreModel, uri: string, cid: string) {
|
||||
return await store.api.app.bsky.feed.repost.create(
|
||||
{did: store.me.did || ''},
|
||||
{
|
||||
subject: {uri, cid},
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function unrepost(store: RootStoreModel, repostUri: string) {
|
||||
const repostUrip = new AtUri(repostUri)
|
||||
return await store.api.app.bsky.feed.repost.delete({
|
||||
did: repostUrip.hostname,
|
||||
rkey: repostUrip.rkey,
|
||||
})
|
||||
}
|
||||
|
||||
export async function follow(
|
||||
store: RootStoreModel,
|
||||
subjectDid: string,
|
||||
subjectDeclarationCid: string,
|
||||
) {
|
||||
return await store.api.app.bsky.graph.follow.create(
|
||||
{did: store.me.did || ''},
|
||||
{
|
||||
subject: {
|
||||
did: subjectDid,
|
||||
declarationCid: subjectDeclarationCid,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function unfollow(store: RootStoreModel, followUri: string) {
|
||||
const followUrip = new AtUri(followUri)
|
||||
return await store.api.app.bsky.graph.follow.delete({
|
||||
did: followUrip.hostname,
|
||||
rkey: followUrip.rkey,
|
||||
})
|
||||
}
|
||||
|
||||
// helpers
|
||||
// =
|
||||
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
import {RootStoreModel} from 'state/index'
|
||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
||||
import {
|
||||
scaleDownDimensions,
|
||||
Dim,
|
||||
compressIfNeeded,
|
||||
moveToPremanantPath,
|
||||
} from 'lib/media/manip'
|
||||
export type {PickedMedia} from './types'
|
||||
import RNFS from 'react-native-fs'
|
||||
|
||||
let _imageCounter = 0
|
||||
async function getFile() {
|
||||
const files = await RNFS.readDir(
|
||||
RNFS.LibraryDirectoryPath.split('/')
|
||||
.slice(0, -5)
|
||||
.concat(['Media', 'DCIM', '100APPLE'])
|
||||
.join('/'),
|
||||
)
|
||||
return files[_imageCounter++ % files.length]
|
||||
}
|
||||
|
||||
export async function openPicker(
|
||||
_store: RootStoreModel,
|
||||
opts: PickerOpts,
|
||||
): Promise<PickedMedia[]> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
const items = await getFile()
|
||||
const toMedia = (item: RNFS.ReadDirItem) => ({
|
||||
mediaType,
|
||||
path: item.path,
|
||||
mime: 'image/jpeg',
|
||||
size: item.size,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
})
|
||||
if (Array.isArray(items)) {
|
||||
return items.map(toMedia)
|
||||
}
|
||||
return [toMedia(items)]
|
||||
}
|
||||
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
opts: CameraOpts,
|
||||
): Promise<PickedMedia> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
const item = await getFile()
|
||||
return {
|
||||
mediaType,
|
||||
path: item.path,
|
||||
mime: 'image/jpeg',
|
||||
size: item.size,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
}
|
||||
}
|
||||
|
||||
export async function openCropper(
|
||||
_store: RootStoreModel,
|
||||
opts: CropperOpts,
|
||||
): Promise<PickedMedia> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
const item = await getFile()
|
||||
return {
|
||||
mediaType,
|
||||
path: item.path,
|
||||
mime: 'image/jpeg',
|
||||
size: item.size,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickImagesFlow(
|
||||
store: RootStoreModel,
|
||||
maxFiles: number,
|
||||
maxDim: Dim,
|
||||
maxSize: number,
|
||||
) {
|
||||
const items = await openPicker(store, {
|
||||
multiple: true,
|
||||
maxFiles,
|
||||
mediaType: 'photo',
|
||||
})
|
||||
const result = []
|
||||
for (const image of items) {
|
||||
result.push(
|
||||
await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function cropAndCompressFlow(
|
||||
store: RootStoreModel,
|
||||
path: string,
|
||||
imgDim: Dim,
|
||||
maxDim: Dim,
|
||||
maxSize: number,
|
||||
) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(imgDim, maxDim)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path,
|
||||
freeStyleCropEnabled: true,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
const img = await compressIfNeeded(cropperRes, maxSize)
|
||||
const permanentPath = await moveToPremanantPath(img.path)
|
||||
return permanentPath
|
||||
}
|
|
@ -45,7 +45,7 @@ export function displayNotificationFromModel(
|
|||
let author = notif.author.displayName || notif.author.handle
|
||||
let title: string
|
||||
let body: string = ''
|
||||
if (notif.isUpvote) {
|
||||
if (notif.isLike) {
|
||||
title = `${author} liked your post`
|
||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notif.isRepost) {
|
||||
|
@ -65,7 +65,7 @@ export function displayNotificationFromModel(
|
|||
}
|
||||
let image
|
||||
if (
|
||||
AppBskyEmbedImages.isPresented(notif.additionalPost?.thread?.post.embed) &&
|
||||
AppBskyEmbedImages.isView(notif.additionalPost?.thread?.post.embed) &&
|
||||
notif.additionalPost?.thread?.post.embed.images[0]?.thumb
|
||||
) {
|
||||
image = notif.additionalPost.thread.post.embed.images[0].thumb
|
||||
|
|
|
@ -10,7 +10,7 @@ export type CommonNavigatorParams = {
|
|||
ProfileFollowers: {name: string}
|
||||
ProfileFollows: {name: string}
|
||||
PostThread: {name: string; rkey: string}
|
||||
PostUpvotedBy: {name: string; rkey: string}
|
||||
PostLikedBy: {name: string; rkey: string}
|
||||
PostRepostedBy: {name: string; rkey: string}
|
||||
Debug: undefined
|
||||
Log: undefined
|
||||
|
|
|
@ -1,64 +1,5 @@
|
|||
import {AppBskyFeedPost} from '@atproto/api'
|
||||
type Entity = AppBskyFeedPost.Entity
|
||||
import {isValidDomain} from './url-helpers'
|
||||
|
||||
export function extractEntities(
|
||||
text: string,
|
||||
knownHandles?: Set<string>,
|
||||
): Entity[] | undefined {
|
||||
let match
|
||||
let ents: Entity[] = []
|
||||
{
|
||||
// mentions
|
||||
const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g
|
||||
while ((match = re.exec(text))) {
|
||||
if (knownHandles && !knownHandles.has(match[3])) {
|
||||
continue // not a known handle
|
||||
} else if (!match[3].includes('.')) {
|
||||
continue // probably not a handle
|
||||
}
|
||||
const start = text.indexOf(match[3], match.index) - 1
|
||||
ents.push({
|
||||
type: 'mention',
|
||||
value: match[3],
|
||||
index: {start, end: start + match[3].length + 1},
|
||||
})
|
||||
}
|
||||
}
|
||||
{
|
||||
// links
|
||||
const re =
|
||||
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
|
||||
while ((match = re.exec(text))) {
|
||||
let value = match[2]
|
||||
if (!value.startsWith('http')) {
|
||||
const domain = match.groups?.domain
|
||||
if (!domain || !isValidDomain(domain)) {
|
||||
continue
|
||||
}
|
||||
value = `https://${value}`
|
||||
}
|
||||
const start = text.indexOf(match[2], match.index)
|
||||
const index = {start, end: start + match[2].length}
|
||||
// strip ending puncuation
|
||||
if (/[.,;!?]$/.test(value)) {
|
||||
value = value.slice(0, -1)
|
||||
index.end--
|
||||
}
|
||||
if (/[)]$/.test(value) && !value.includes('(')) {
|
||||
value = value.slice(0, -1)
|
||||
index.end--
|
||||
}
|
||||
ents.push({
|
||||
type: 'link',
|
||||
value,
|
||||
index,
|
||||
})
|
||||
}
|
||||
}
|
||||
return ents.length > 0 ? ents : undefined
|
||||
}
|
||||
|
||||
interface DetectedLink {
|
||||
link: string
|
||||
}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
import {RichText} from './rich-text'
|
||||
|
||||
const EXCESS_SPACE_RE = /[\r\n]([\u00AD\u2060\u200D\u200C\u200B\s]*[\r\n]){2,}/
|
||||
const REPLACEMENT_STR = '\n\n'
|
||||
|
||||
export function removeExcessNewlines(richText: RichText): RichText {
|
||||
return clean(richText, EXCESS_SPACE_RE, REPLACEMENT_STR)
|
||||
}
|
||||
|
||||
// TODO: check on whether this works correctly with multi-byte codepoints
|
||||
export function clean(
|
||||
richText: RichText,
|
||||
targetRegexp: RegExp,
|
||||
replacementString: string,
|
||||
): RichText {
|
||||
richText = richText.clone()
|
||||
|
||||
let match = richText.text.match(targetRegexp)
|
||||
while (match && typeof match.index !== 'undefined') {
|
||||
const oldText = richText.text
|
||||
const removeStartIndex = match.index
|
||||
const removeEndIndex = removeStartIndex + match[0].length
|
||||
richText.delete(removeStartIndex, removeEndIndex)
|
||||
if (richText.text === oldText) {
|
||||
break // sanity check
|
||||
}
|
||||
richText.insert(removeStartIndex, replacementString)
|
||||
match = richText.text.match(targetRegexp)
|
||||
}
|
||||
|
||||
return richText
|
||||
}
|
|
@ -1,216 +0,0 @@
|
|||
/*
|
||||
= Rich Text Manipulation
|
||||
|
||||
When we sanitize rich text, we have to update the entity indices as the
|
||||
text is modified. This can be modeled as inserts() and deletes() of the
|
||||
rich text string. The possible scenarios are outlined below, along with
|
||||
their expected behaviors.
|
||||
|
||||
NOTE: Slices are start inclusive, end exclusive
|
||||
|
||||
== richTextInsert()
|
||||
|
||||
Target string:
|
||||
|
||||
0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l o w o r l d // string value
|
||||
^-------^ // target slice {start: 2, end: 7}
|
||||
|
||||
Scenarios:
|
||||
|
||||
A: ^ // insert "test" at 0
|
||||
B: ^ // insert "test" at 4
|
||||
C: ^ // insert "test" at 8
|
||||
|
||||
A = before -> move both by num added
|
||||
B = inner -> move end by num added
|
||||
C = after -> noop
|
||||
|
||||
Results:
|
||||
|
||||
A: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
t e s t h e l l o w // string value
|
||||
^-------^ // target slice {start: 6, end: 11}
|
||||
|
||||
B: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l t e s t o w // string value
|
||||
^---------------^ // target slice {start: 2, end: 11}
|
||||
|
||||
C: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l o w o t e s // string value
|
||||
^-------^ // target slice {start: 2, end: 7}
|
||||
|
||||
== richTextDelete()
|
||||
|
||||
Target string:
|
||||
|
||||
0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l o w o r l d // string value
|
||||
^-------^ // target slice {start: 2, end: 7}
|
||||
|
||||
Scenarios:
|
||||
|
||||
A: ^---------------^ // remove slice {start: 0, end: 9}
|
||||
B: ^-----^ // remove slice {start: 7, end: 11}
|
||||
C: ^-----------^ // remove slice {start: 4, end: 11}
|
||||
D: ^-^ // remove slice {start: 3, end: 5}
|
||||
E: ^-----^ // remove slice {start: 1, end: 5}
|
||||
F: ^-^ // remove slice {start: 0, end: 2}
|
||||
|
||||
A = entirely outer -> delete slice
|
||||
B = entirely after -> noop
|
||||
C = partially after -> move end to remove-start
|
||||
D = entirely inner -> move end by num removed
|
||||
E = partially before -> move start to remove-start index, move end by num removed
|
||||
F = entirely before -> move both by num removed
|
||||
|
||||
Results:
|
||||
|
||||
A: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
l d // string value
|
||||
// target slice (deleted)
|
||||
|
||||
B: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l o w // string value
|
||||
^-------^ // target slice {start: 2, end: 7}
|
||||
|
||||
C: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l // string value
|
||||
^-^ // target slice {start: 2, end: 4}
|
||||
|
||||
D: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l w o r l d // string value
|
||||
^---^ // target slice {start: 2, end: 5}
|
||||
|
||||
E: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h w o r l d // string value
|
||||
^-^ // target slice {start: 1, end: 3}
|
||||
|
||||
F: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
l l o w o r l d // string value
|
||||
^-------^ // target slice {start: 0, end: 5}
|
||||
*/
|
||||
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
import {AppBskyFeedPost} from '@atproto/api'
|
||||
import {removeExcessNewlines} from './rich-text-sanitize'
|
||||
|
||||
export type Entity = AppBskyFeedPost.Entity
|
||||
export interface RichTextOpts {
|
||||
cleanNewlines?: boolean
|
||||
}
|
||||
|
||||
export class RichText {
|
||||
constructor(
|
||||
public text: string,
|
||||
public entities?: Entity[],
|
||||
opts?: RichTextOpts,
|
||||
) {
|
||||
if (opts?.cleanNewlines) {
|
||||
removeExcessNewlines(this).copyInto(this)
|
||||
}
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new RichText(this.text, cloneDeep(this.entities))
|
||||
}
|
||||
|
||||
copyInto(target: RichText) {
|
||||
target.text = this.text
|
||||
target.entities = cloneDeep(this.entities)
|
||||
}
|
||||
|
||||
insert(insertIndex: number, insertText: string) {
|
||||
this.text =
|
||||
this.text.slice(0, insertIndex) +
|
||||
insertText +
|
||||
this.text.slice(insertIndex)
|
||||
|
||||
if (!this.entities?.length) {
|
||||
return this
|
||||
}
|
||||
|
||||
const numCharsAdded = insertText.length
|
||||
for (const ent of this.entities) {
|
||||
// see comment at top of file for labels of each scenario
|
||||
// scenario A (before)
|
||||
if (insertIndex <= ent.index.start) {
|
||||
// move both by num added
|
||||
ent.index.start += numCharsAdded
|
||||
ent.index.end += numCharsAdded
|
||||
}
|
||||
// scenario B (inner)
|
||||
else if (insertIndex >= ent.index.start && insertIndex < ent.index.end) {
|
||||
// move end by num added
|
||||
ent.index.end += numCharsAdded
|
||||
}
|
||||
// scenario C (after)
|
||||
// noop
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
delete(removeStartIndex: number, removeEndIndex: number) {
|
||||
this.text =
|
||||
this.text.slice(0, removeStartIndex) + this.text.slice(removeEndIndex)
|
||||
|
||||
if (!this.entities?.length) {
|
||||
return this
|
||||
}
|
||||
|
||||
const numCharsRemoved = removeEndIndex - removeStartIndex
|
||||
for (const ent of this.entities) {
|
||||
// see comment at top of file for labels of each scenario
|
||||
// scenario A (entirely outer)
|
||||
if (
|
||||
removeStartIndex <= ent.index.start &&
|
||||
removeEndIndex >= ent.index.end
|
||||
) {
|
||||
// delete slice (will get removed in final pass)
|
||||
ent.index.start = 0
|
||||
ent.index.end = 0
|
||||
}
|
||||
// scenario B (entirely after)
|
||||
else if (removeStartIndex > ent.index.end) {
|
||||
// noop
|
||||
}
|
||||
// scenario C (partially after)
|
||||
else if (
|
||||
removeStartIndex > ent.index.start &&
|
||||
removeStartIndex <= ent.index.end &&
|
||||
removeEndIndex > ent.index.end
|
||||
) {
|
||||
// move end to remove start
|
||||
ent.index.end = removeStartIndex
|
||||
}
|
||||
// scenario D (entirely inner)
|
||||
else if (
|
||||
removeStartIndex >= ent.index.start &&
|
||||
removeEndIndex <= ent.index.end
|
||||
) {
|
||||
// move end by num removed
|
||||
ent.index.end -= numCharsRemoved
|
||||
}
|
||||
// scenario E (partially before)
|
||||
else if (
|
||||
removeStartIndex < ent.index.start &&
|
||||
removeEndIndex >= ent.index.start &&
|
||||
removeEndIndex <= ent.index.end
|
||||
) {
|
||||
// move start to remove-start index, move end by num removed
|
||||
ent.index.start = removeStartIndex
|
||||
ent.index.end -= numCharsRemoved
|
||||
}
|
||||
// scenario F (entirely before)
|
||||
else if (removeEndIndex < ent.index.start) {
|
||||
// move both by num removed
|
||||
ent.index.start -= numCharsRemoved
|
||||
ent.index.end -= numCharsRemoved
|
||||
}
|
||||
}
|
||||
|
||||
// filter out any entities that were made irrelevant
|
||||
this.entities = this.entities.filter(ent => ent.index.start < ent.index.end)
|
||||
return this
|
||||
}
|
||||
}
|
|
@ -71,6 +71,7 @@ export const s = StyleSheet.create({
|
|||
borderBottom1: {borderBottomWidth: 1},
|
||||
borderLeft1: {borderLeftWidth: 1},
|
||||
hidden: {display: 'none'},
|
||||
dimmed: {opacity: 0.5},
|
||||
|
||||
// font weights
|
||||
fw600: {fontWeight: '600'},
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'fast-text-encoding'
|
||||
import Graphemer from 'graphemer'
|
||||
export {}
|
||||
|
||||
/**
|
||||
|
@ -48,3 +50,18 @@ globalThis.atob = (str: string): string => {
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const splitter = new Graphemer()
|
||||
globalThis.Intl = globalThis.Intl || {}
|
||||
|
||||
// @ts-ignore we're polyfilling -prf
|
||||
globalThis.Intl.Segmenter =
|
||||
// @ts-ignore we're polyfilling -prf
|
||||
globalThis.Intl.Segmenter ||
|
||||
class Segmenter {
|
||||
constructor() {}
|
||||
// NOTE
|
||||
// this is not a precisely correct polyfill but it's sufficient for our needs
|
||||
// -prf
|
||||
segment = splitter.iterateGraphemes
|
||||
}
|
||||
|
|
|
@ -2,3 +2,11 @@
|
|||
|
||||
// @ts-ignore whatever typescript wants to complain about here, I dont care about -prf
|
||||
window.setImmediate = (cb: () => void) => setTimeout(cb, 0)
|
||||
|
||||
// @ts-ignore not on the TS signature due to bad support -prf
|
||||
if (!globalThis.Intl?.Segmenter) {
|
||||
// NOTE loading as a separate script to reduce main bundle size, as this is only needed in FF -prf
|
||||
const script = document.createElement('script')
|
||||
script.setAttribute('src', '/static/js/intl-segmenter-polyfill.min.js')
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ export const router = new Router({
|
|||
ProfileFollowers: '/profile/:name/followers',
|
||||
ProfileFollows: '/profile/:name/follows',
|
||||
PostThread: '/profile/:name/post/:rkey',
|
||||
PostUpvotedBy: '/profile/:name/post/:rkey/upvoted-by',
|
||||
PostLikedBy: '/profile/:name/post/:rkey/liked-by',
|
||||
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
|
||||
Debug: '/sys/debug',
|
||||
Log: '/sys/log',
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {autorun} from 'mobx'
|
||||
import {AppState, Platform} from 'react-native'
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {RootStoreModel} from './models/root-store'
|
||||
import * as apiPolyfill from 'lib/api/api-polyfill'
|
||||
import * as storage from 'lib/storage'
|
||||
|
@ -19,7 +19,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
|
|||
|
||||
apiPolyfill.doPolyfill()
|
||||
|
||||
rootStore = new RootStoreModel(new AtpAgent({service: serviceUri}))
|
||||
rootStore = new RootStoreModel(new BskyAgent({service: serviceUri}))
|
||||
try {
|
||||
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
|
||||
rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
|
||||
|
|
|
@ -3,7 +3,7 @@ import {Dim} from 'lib/media/manip'
|
|||
|
||||
export class ImageSizesCache {
|
||||
sizes: Map<string, Dim> = new Map()
|
||||
private activeRequests: Map<string, Promise<Dim>> = new Map()
|
||||
activeRequests: Map<string, Promise<Dim>> = new Map()
|
||||
|
||||
constructor() {}
|
||||
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
|
||||
import {FollowRecord, AppBskyActorDefs} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
|
||||
const CACHE_TTL = 1000 * 60 * 60 // hourly
|
||||
type FollowsListResponse = Awaited<ReturnType<FollowRecord['list']>>
|
||||
type FollowsListResponseRecord = FollowsListResponse['records'][0]
|
||||
type Profile =
|
||||
| AppBskyActorProfile.ViewBasic
|
||||
| AppBskyActorProfile.View
|
||||
| AppBskyActorRef.WithInfo
|
||||
type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
|
||||
|
||||
/**
|
||||
* This model is used to maintain a synced local cache of the user's
|
||||
|
@ -53,21 +50,21 @@ export class MyFollowsCache {
|
|||
|
||||
fetch = bundleAsync(async () => {
|
||||
this.rootStore.log.debug('MyFollowsModel:fetch running full fetch')
|
||||
let before
|
||||
let rkeyStart
|
||||
let records: FollowsListResponseRecord[] = []
|
||||
do {
|
||||
const res: FollowsListResponse =
|
||||
await this.rootStore.api.app.bsky.graph.follow.list({
|
||||
user: this.rootStore.me.did,
|
||||
before,
|
||||
await this.rootStore.agent.app.bsky.graph.follow.list({
|
||||
repo: this.rootStore.me.did,
|
||||
rkeyStart,
|
||||
})
|
||||
records = records.concat(res.records)
|
||||
before = res.cursor
|
||||
} while (typeof before !== 'undefined')
|
||||
rkeyStart = res.cursor
|
||||
} while (typeof rkeyStart !== 'undefined')
|
||||
runInAction(() => {
|
||||
this.followDidToRecordMap = {}
|
||||
for (const record of records) {
|
||||
this.followDidToRecordMap[record.value.subject.did] = record.uri
|
||||
this.followDidToRecordMap[record.value.subject] = record.uri
|
||||
}
|
||||
this.lastSync = Date.now()
|
||||
this.myDid = this.rootStore.me.did
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import {AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import sampleSize from 'lodash.samplesize'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
|
||||
export type RefWithInfoAndFollowers = AppBskyActorRef.WithInfo & {
|
||||
followers: AppBskyActorProfile.View[]
|
||||
export type RefWithInfoAndFollowers = AppBskyActorDefs.ProfileViewBasic & {
|
||||
followers: AppBskyActorDefs.ProfileView[]
|
||||
}
|
||||
|
||||
export type ProfileViewFollows = AppBskyActorProfile.View & {
|
||||
follows: AppBskyActorRef.WithInfo[]
|
||||
export type ProfileViewFollows = AppBskyActorDefs.ProfileView & {
|
||||
follows: AppBskyActorDefs.ProfileViewBasic[]
|
||||
}
|
||||
|
||||
export class FoafsModel {
|
||||
|
@ -51,14 +51,14 @@ export class FoafsModel {
|
|||
this.popular.length = 0
|
||||
|
||||
// fetch their profiles
|
||||
const profiles = await this.rootStore.api.app.bsky.actor.getProfiles({
|
||||
const profiles = await this.rootStore.agent.getProfiles({
|
||||
actors: this.sources,
|
||||
})
|
||||
|
||||
// fetch their follows
|
||||
const results = await Promise.allSettled(
|
||||
this.sources.map(source =>
|
||||
this.rootStore.api.app.bsky.graph.getFollows({user: source}),
|
||||
this.rootStore.agent.getFollows({actor: source}),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {AppBskyActorProfile as Profile} from '@atproto/api'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import shuffle from 'lodash.shuffle'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
@ -8,7 +8,9 @@ import {SUGGESTED_FOLLOWS} from 'lib/constants'
|
|||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export type SuggestedActor = Profile.ViewBasic | Profile.View
|
||||
export type SuggestedActor =
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| AppBskyActorDefs.ProfileView
|
||||
|
||||
export class SuggestedActorsModel {
|
||||
// state
|
||||
|
@ -20,7 +22,7 @@ export class SuggestedActorsModel {
|
|||
hasMore = true
|
||||
loadMoreCursor?: string
|
||||
|
||||
private hardCodedSuggestions: SuggestedActor[] | undefined
|
||||
hardCodedSuggestions: SuggestedActor[] | undefined
|
||||
|
||||
// data
|
||||
suggestions: SuggestedActor[] = []
|
||||
|
@ -82,7 +84,7 @@ export class SuggestedActorsModel {
|
|||
this.loadMoreCursor = undefined
|
||||
} else {
|
||||
// pull from the PDS' algo
|
||||
res = await this.rootStore.api.app.bsky.actor.getSuggestions({
|
||||
res = await this.rootStore.agent.app.bsky.actor.getSuggestions({
|
||||
limit: this.pageSize,
|
||||
cursor: this.loadMoreCursor,
|
||||
})
|
||||
|
@ -104,7 +106,7 @@ export class SuggestedActorsModel {
|
|||
}
|
||||
})
|
||||
|
||||
private async fetchHardcodedSuggestions() {
|
||||
async fetchHardcodedSuggestions() {
|
||||
if (this.hardCodedSuggestions) {
|
||||
return
|
||||
}
|
||||
|
@ -118,9 +120,9 @@ export class SuggestedActorsModel {
|
|||
]
|
||||
|
||||
// fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)
|
||||
let profiles: Profile.View[] = []
|
||||
let profiles: AppBskyActorDefs.ProfileView[] = []
|
||||
do {
|
||||
const res = await this.rootStore.api.app.bsky.actor.getProfiles({
|
||||
const res = await this.rootStore.agent.getProfiles({
|
||||
actors: actors.splice(0, 25),
|
||||
})
|
||||
profiles = profiles.concat(res.data.profiles)
|
||||
|
@ -152,13 +154,13 @@ export class SuggestedActorsModel {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
|
|
|
@ -1,32 +1,29 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {
|
||||
AppBskyFeedGetTimeline as GetTimeline,
|
||||
AppBskyFeedFeedViewPost,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import AwaitLock from 'await-lock'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import sampleSize from 'lodash.samplesize'
|
||||
type FeedViewPost = AppBskyFeedFeedViewPost.Main
|
||||
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
|
||||
type PostView = AppBskyFeedPost.View
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {RichText} from 'lib/strings/rich-text'
|
||||
import {SUGGESTED_FOLLOWS} from 'lib/constants'
|
||||
import {
|
||||
getCombinedCursors,
|
||||
getMultipleAuthorsPosts,
|
||||
mergePosts,
|
||||
} from 'lib/api/build-suggested-posts'
|
||||
|
||||
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
|
||||
type PostView = AppBskyFeedDefs.PostView
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
let _idCounter = 0
|
||||
|
||||
export class FeedItemModel {
|
||||
|
@ -51,11 +48,7 @@ export class FeedItemModel {
|
|||
const valid = AppBskyFeedPost.validateRecord(this.post.record)
|
||||
if (valid.success) {
|
||||
this.postRecord = this.post.record
|
||||
this.richText = new RichText(
|
||||
this.postRecord.text,
|
||||
this.postRecord.entities,
|
||||
{cleanNewlines: true},
|
||||
)
|
||||
this.richText = new RichText(this.postRecord, {cleanNewlines: true})
|
||||
} else {
|
||||
rootStore.log.warn(
|
||||
'Received an invalid app.bsky.feed.post record',
|
||||
|
@ -82,7 +75,7 @@ export class FeedItemModel {
|
|||
copyMetrics(v: FeedViewPost) {
|
||||
this.post.replyCount = v.post.replyCount
|
||||
this.post.repostCount = v.post.repostCount
|
||||
this.post.upvoteCount = v.post.upvoteCount
|
||||
this.post.likeCount = v.post.likeCount
|
||||
this.post.viewer = v.post.viewer
|
||||
}
|
||||
|
||||
|
@ -92,68 +85,43 @@ export class FeedItemModel {
|
|||
}
|
||||
}
|
||||
|
||||
async toggleUpvote() {
|
||||
const wasUpvoted = !!this.post.viewer.upvote
|
||||
const wasDownvoted = !!this.post.viewer.downvote
|
||||
const res = await this.rootStore.api.app.bsky.feed.setVote({
|
||||
subject: {
|
||||
uri: this.post.uri,
|
||||
cid: this.post.cid,
|
||||
},
|
||||
direction: wasUpvoted ? 'none' : 'up',
|
||||
})
|
||||
async toggleLike() {
|
||||
if (this.post.viewer?.like) {
|
||||
await this.rootStore.agent.deleteLike(this.post.viewer.like)
|
||||
runInAction(() => {
|
||||
if (wasDownvoted) {
|
||||
this.post.downvoteCount--
|
||||
}
|
||||
if (wasUpvoted) {
|
||||
this.post.upvoteCount--
|
||||
this.post.likeCount = this.post.likeCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.likeCount--
|
||||
this.post.viewer.like = undefined
|
||||
})
|
||||
} else {
|
||||
this.post.upvoteCount++
|
||||
}
|
||||
this.post.viewer.upvote = res.data.upvote
|
||||
this.post.viewer.downvote = res.data.downvote
|
||||
})
|
||||
}
|
||||
|
||||
async toggleDownvote() {
|
||||
const wasUpvoted = !!this.post.viewer.upvote
|
||||
const wasDownvoted = !!this.post.viewer.downvote
|
||||
const res = await this.rootStore.api.app.bsky.feed.setVote({
|
||||
subject: {
|
||||
uri: this.post.uri,
|
||||
cid: this.post.cid,
|
||||
},
|
||||
direction: wasDownvoted ? 'none' : 'down',
|
||||
})
|
||||
const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
|
||||
runInAction(() => {
|
||||
if (wasUpvoted) {
|
||||
this.post.upvoteCount--
|
||||
}
|
||||
if (wasDownvoted) {
|
||||
this.post.downvoteCount--
|
||||
} else {
|
||||
this.post.downvoteCount++
|
||||
}
|
||||
this.post.viewer.upvote = res.data.upvote
|
||||
this.post.viewer.downvote = res.data.downvote
|
||||
this.post.likeCount = this.post.likeCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.likeCount++
|
||||
this.post.viewer.like = res.uri
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async toggleRepost() {
|
||||
if (this.post.viewer.repost) {
|
||||
await apilib.unrepost(this.rootStore, this.post.viewer.repost)
|
||||
if (this.post.viewer?.repost) {
|
||||
await this.rootStore.agent.deleteRepost(this.post.viewer.repost)
|
||||
runInAction(() => {
|
||||
this.post.repostCount = this.post.repostCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.repostCount--
|
||||
this.post.viewer.repost = undefined
|
||||
})
|
||||
} else {
|
||||
const res = await apilib.repost(
|
||||
this.rootStore,
|
||||
const res = await this.rootStore.agent.repost(
|
||||
this.post.uri,
|
||||
this.post.cid,
|
||||
)
|
||||
runInAction(() => {
|
||||
this.post.repostCount = this.post.repostCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.repostCount++
|
||||
this.post.viewer.repost = res.uri
|
||||
})
|
||||
|
@ -161,10 +129,7 @@ export class FeedItemModel {
|
|||
}
|
||||
|
||||
async delete() {
|
||||
await this.rootStore.api.app.bsky.feed.post.delete({
|
||||
did: this.post.author.did,
|
||||
rkey: new AtUri(this.post.uri).rkey,
|
||||
})
|
||||
await this.rootStore.agent.deletePost(this.post.uri)
|
||||
this.rootStore.emitPostDeleted(this.post.uri)
|
||||
}
|
||||
}
|
||||
|
@ -250,7 +215,7 @@ export class FeedModel {
|
|||
tuner = new FeedTuner()
|
||||
|
||||
// used to linearize async modifications to state
|
||||
private lock = new AwaitLock()
|
||||
lock = new AwaitLock()
|
||||
|
||||
// data
|
||||
slices: FeedSliceModel[] = []
|
||||
|
@ -291,8 +256,8 @@ export class FeedModel {
|
|||
const params = this.params as GetAuthorFeed.QueryParams
|
||||
const item = slice.rootItem
|
||||
const isRepost =
|
||||
item?.reasonRepost?.by?.handle === params.author ||
|
||||
item?.reasonRepost?.by?.did === params.author
|
||||
item?.reasonRepost?.by?.handle === params.actor ||
|
||||
item?.reasonRepost?.by?.did === params.actor
|
||||
return (
|
||||
!item.reply || // not a reply
|
||||
isRepost || // but allow if it's a repost
|
||||
|
@ -338,7 +303,7 @@ export class FeedModel {
|
|||
return this.setup()
|
||||
}
|
||||
|
||||
private get feedTuners() {
|
||||
get feedTuners() {
|
||||
if (this.feedType === 'goodstuff') {
|
||||
return [
|
||||
FeedTuner.dedupReposts,
|
||||
|
@ -406,7 +371,7 @@ export class FeedModel {
|
|||
this._xLoading()
|
||||
try {
|
||||
const res = await this._getFeed({
|
||||
before: this.loadMoreCursor,
|
||||
cursor: this.loadMoreCursor,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
await this._appendAll(res)
|
||||
|
@ -439,7 +404,7 @@ export class FeedModel {
|
|||
try {
|
||||
do {
|
||||
const res: GetTimeline.Response = await this._getFeed({
|
||||
before: cursor,
|
||||
cursor,
|
||||
limit: Math.min(numToFetch, 100),
|
||||
})
|
||||
if (res.data.feed.length === 0) {
|
||||
|
@ -478,14 +443,18 @@ export class FeedModel {
|
|||
new FeedSliceModel(this.rootStore, `item-${_idCounter++}`, slice),
|
||||
)
|
||||
if (autoPrepend) {
|
||||
runInAction(() => {
|
||||
this.slices = nextSlicesModels.concat(
|
||||
this.slices.filter(slice1 =>
|
||||
nextSlicesModels.find(slice2 => slice1.uri === slice2.uri),
|
||||
),
|
||||
)
|
||||
this.setHasNewLatest(false)
|
||||
})
|
||||
} else {
|
||||
runInAction(() => {
|
||||
this.nextSlices = nextSlicesModels
|
||||
})
|
||||
this.setHasNewLatest(true)
|
||||
}
|
||||
} else {
|
||||
|
@ -519,13 +488,13 @@ export class FeedModel {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
|
@ -538,14 +507,12 @@ export class FeedModel {
|
|||
// helper functions
|
||||
// =
|
||||
|
||||
private async _replaceAll(
|
||||
res: GetTimeline.Response | GetAuthorFeed.Response,
|
||||
) {
|
||||
async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
|
||||
this.pollCursor = res.data.feed[0]?.post.uri
|
||||
return this._appendAll(res, true)
|
||||
}
|
||||
|
||||
private async _appendAll(
|
||||
async _appendAll(
|
||||
res: GetTimeline.Response | GetAuthorFeed.Response,
|
||||
replace = false,
|
||||
) {
|
||||
|
@ -572,7 +539,7 @@ export class FeedModel {
|
|||
})
|
||||
}
|
||||
|
||||
private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
|
||||
_updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
|
||||
for (const item of res.data.feed) {
|
||||
const existingSlice = this.slices.find(slice =>
|
||||
slice.containsUri(item.post.uri),
|
||||
|
@ -596,7 +563,7 @@ export class FeedModel {
|
|||
const responses = await getMultipleAuthorsPosts(
|
||||
this.rootStore,
|
||||
sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20),
|
||||
params.before,
|
||||
params.cursor,
|
||||
20,
|
||||
)
|
||||
const combinedCursor = getCombinedCursors(responses)
|
||||
|
@ -611,9 +578,7 @@ export class FeedModel {
|
|||
headers: lastHeaders,
|
||||
}
|
||||
} else if (this.feedType === 'home') {
|
||||
return this.rootStore.api.app.bsky.feed.getTimeline(
|
||||
params as GetTimeline.QueryParams,
|
||||
)
|
||||
return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
|
||||
} else if (this.feedType === 'goodstuff') {
|
||||
const res = await getGoodStuff(
|
||||
this.rootStore.session.currentSession?.accessJwt || '',
|
||||
|
@ -624,7 +589,7 @@ export class FeedModel {
|
|||
)
|
||||
return res
|
||||
} else {
|
||||
return this.rootStore.api.app.bsky.feed.getAuthorFeed(
|
||||
return this.rootStore.agent.getAuthorFeed(
|
||||
params as GetAuthorFeed.QueryParams,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api'
|
||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
|
@ -8,24 +8,24 @@ import * as apilib from 'lib/api/index'
|
|||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export type VoteItem = GetVotes.Vote
|
||||
export type LikeItem = GetLikes.Like
|
||||
|
||||
export class VotesViewModel {
|
||||
export class LikesViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
resolvedUri = ''
|
||||
params: GetVotes.QueryParams
|
||||
params: GetLikes.QueryParams
|
||||
hasMore = true
|
||||
loadMoreCursor?: string
|
||||
|
||||
// data
|
||||
uri: string = ''
|
||||
votes: VoteItem[] = []
|
||||
likes: LikeItem[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel, params: GetVotes.QueryParams) {
|
||||
constructor(public rootStore: RootStoreModel, params: GetLikes.QueryParams) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
|
@ -68,9 +68,9 @@ export class VotesViewModel {
|
|||
const params = Object.assign({}, this.params, {
|
||||
uri: this.resolvedUri,
|
||||
limit: PAGE_SIZE,
|
||||
before: replace ? undefined : this.loadMoreCursor,
|
||||
cursor: replace ? undefined : this.loadMoreCursor,
|
||||
})
|
||||
const res = await this.rootStore.api.app.bsky.feed.getVotes(params)
|
||||
const res = await this.rootStore.agent.getLikes(params)
|
||||
if (replace) {
|
||||
this._replaceAll(res)
|
||||
} else {
|
||||
|
@ -85,13 +85,13 @@ export class VotesViewModel {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
|
@ -104,7 +104,7 @@ export class VotesViewModel {
|
|||
// helper functions
|
||||
// =
|
||||
|
||||
private async _resolveUri() {
|
||||
async _resolveUri() {
|
||||
const urip = new AtUri(this.params.uri)
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
try {
|
||||
|
@ -118,14 +118,14 @@ export class VotesViewModel {
|
|||
})
|
||||
}
|
||||
|
||||
private _replaceAll(res: GetVotes.Response) {
|
||||
this.votes = []
|
||||
_replaceAll(res: GetLikes.Response) {
|
||||
this.likes = []
|
||||
this._appendAll(res)
|
||||
}
|
||||
|
||||
private _appendAll(res: GetVotes.Response) {
|
||||
_appendAll(res: GetLikes.Response) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
this.votes = this.votes.concat(res.data.votes)
|
||||
this.likes = this.likes.concat(res.data.likes)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc'
|
||||
// import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' TODO
|
||||
|
||||
const MAX_ENTRIES = 300
|
||||
|
||||
|
@ -32,7 +32,7 @@ export class LogModel {
|
|||
makeAutoObservable(this)
|
||||
}
|
||||
|
||||
private add(entry: LogEntry) {
|
||||
add(entry: LogEntry) {
|
||||
this.entries.push(entry)
|
||||
while (this.entries.length > MAX_ENTRIES) {
|
||||
this.entries = this.entries.slice(50)
|
||||
|
@ -79,14 +79,14 @@ export class LogModel {
|
|||
function detailsToStr(details?: any) {
|
||||
if (details && typeof details !== 'string') {
|
||||
if (
|
||||
details instanceof XRPCInvalidResponseError ||
|
||||
// details instanceof XRPCInvalidResponseError || TODO
|
||||
details.constructor.name === 'XRPCInvalidResponseError'
|
||||
) {
|
||||
return `The server gave an ill-formatted response.\nMethod: ${
|
||||
details.lexiconNsid
|
||||
}.\nError: ${details.validationError.toString()}`
|
||||
} else if (
|
||||
details instanceof XRPCError ||
|
||||
// details instanceof XRPCError || TODO
|
||||
details.constructor.name === 'XRPCError'
|
||||
) {
|
||||
return `An XRPC error occurred.\nStatus: ${details.status}\nError: ${details.error}\nMessage: ${details.message}`
|
||||
|
|
|
@ -85,7 +85,7 @@ export class MeModel {
|
|||
if (sess.hasSession) {
|
||||
this.did = sess.currentSession?.did || ''
|
||||
this.handle = sess.currentSession?.handle || ''
|
||||
const profile = await this.rootStore.api.app.bsky.actor.getProfile({
|
||||
const profile = await this.rootStore.agent.getProfile({
|
||||
actor: this.did,
|
||||
})
|
||||
runInAction(() => {
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {
|
||||
AppBskyNotificationList as ListNotifications,
|
||||
AppBskyActorRef as ActorRef,
|
||||
AppBskyNotificationListNotifications as ListNotifications,
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedRepost,
|
||||
AppBskyFeedVote,
|
||||
AppBskyGraphAssertion,
|
||||
AppBskyFeedLike,
|
||||
AppBskyGraphFollow,
|
||||
} from '@atproto/api'
|
||||
import AwaitLock from 'await-lock'
|
||||
|
@ -28,8 +27,7 @@ export interface GroupedNotification extends ListNotifications.Notification {
|
|||
type SupportedRecord =
|
||||
| AppBskyFeedPost.Record
|
||||
| AppBskyFeedRepost.Record
|
||||
| AppBskyFeedVote.Record
|
||||
| AppBskyGraphAssertion.Record
|
||||
| AppBskyFeedLike.Record
|
||||
| AppBskyGraphFollow.Record
|
||||
|
||||
export class NotificationsViewItemModel {
|
||||
|
@ -39,11 +37,10 @@ export class NotificationsViewItemModel {
|
|||
// data
|
||||
uri: string = ''
|
||||
cid: string = ''
|
||||
author: ActorRef.WithInfo = {
|
||||
author: AppBskyActorDefs.ProfileViewBasic = {
|
||||
did: '',
|
||||
handle: '',
|
||||
avatar: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
}
|
||||
reason: string = ''
|
||||
reasonSubject?: string
|
||||
|
@ -86,8 +83,8 @@ export class NotificationsViewItemModel {
|
|||
}
|
||||
}
|
||||
|
||||
get isUpvote() {
|
||||
return this.reason === 'vote'
|
||||
get isLike() {
|
||||
return this.reason === 'like'
|
||||
}
|
||||
|
||||
get isRepost() {
|
||||
|
@ -102,16 +99,22 @@ export class NotificationsViewItemModel {
|
|||
return this.reason === 'reply'
|
||||
}
|
||||
|
||||
get isQuote() {
|
||||
return this.reason === 'quote'
|
||||
}
|
||||
|
||||
get isFollow() {
|
||||
return this.reason === 'follow'
|
||||
}
|
||||
|
||||
get isAssertion() {
|
||||
return this.reason === 'assertion'
|
||||
}
|
||||
|
||||
get needsAdditionalData() {
|
||||
if (this.isUpvote || this.isRepost || this.isReply || this.isMention) {
|
||||
if (
|
||||
this.isLike ||
|
||||
this.isRepost ||
|
||||
this.isReply ||
|
||||
this.isQuote ||
|
||||
this.isMention
|
||||
) {
|
||||
return !this.additionalPost
|
||||
}
|
||||
return false
|
||||
|
@ -124,7 +127,7 @@ export class NotificationsViewItemModel {
|
|||
const record = this.record
|
||||
if (
|
||||
AppBskyFeedRepost.isRecord(record) ||
|
||||
AppBskyFeedVote.isRecord(record)
|
||||
AppBskyFeedLike.isRecord(record)
|
||||
) {
|
||||
return record.subject.uri
|
||||
}
|
||||
|
@ -135,8 +138,7 @@ export class NotificationsViewItemModel {
|
|||
for (const ns of [
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedRepost,
|
||||
AppBskyFeedVote,
|
||||
AppBskyGraphAssertion,
|
||||
AppBskyFeedLike,
|
||||
AppBskyGraphFollow,
|
||||
]) {
|
||||
if (ns.isRecord(v)) {
|
||||
|
@ -163,9 +165,9 @@ export class NotificationsViewItemModel {
|
|||
return
|
||||
}
|
||||
let postUri
|
||||
if (this.isReply || this.isMention) {
|
||||
if (this.isReply || this.isQuote || this.isMention) {
|
||||
postUri = this.uri
|
||||
} else if (this.isUpvote || this.isRepost) {
|
||||
} else if (this.isLike || this.isRepost) {
|
||||
postUri = this.subjectUri
|
||||
}
|
||||
if (postUri) {
|
||||
|
@ -194,7 +196,7 @@ export class NotificationsViewModel {
|
|||
loadMoreCursor?: string
|
||||
|
||||
// used to linearize async modifications to state
|
||||
private lock = new AwaitLock()
|
||||
lock = new AwaitLock()
|
||||
|
||||
// data
|
||||
notifications: NotificationsViewItemModel[] = []
|
||||
|
@ -266,7 +268,7 @@ export class NotificationsViewModel {
|
|||
const params = Object.assign({}, this.params, {
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
const res = await this.rootStore.api.app.bsky.notification.list(params)
|
||||
const res = await this.rootStore.agent.listNotifications(params)
|
||||
await this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
|
@ -297,9 +299,9 @@ export class NotificationsViewModel {
|
|||
try {
|
||||
const params = Object.assign({}, this.params, {
|
||||
limit: PAGE_SIZE,
|
||||
before: this.loadMoreCursor,
|
||||
cursor: this.loadMoreCursor,
|
||||
})
|
||||
const res = await this.rootStore.api.app.bsky.notification.list(params)
|
||||
const res = await this.rootStore.agent.listNotifications(params)
|
||||
await this._appendAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
|
@ -325,7 +327,7 @@ export class NotificationsViewModel {
|
|||
try {
|
||||
this._xLoading()
|
||||
try {
|
||||
const res = await this.rootStore.api.app.bsky.notification.list({
|
||||
const res = await this.rootStore.agent.listNotifications({
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
await this._prependAll(res)
|
||||
|
@ -357,8 +359,8 @@ export class NotificationsViewModel {
|
|||
try {
|
||||
do {
|
||||
const res: ListNotifications.Response =
|
||||
await this.rootStore.api.app.bsky.notification.list({
|
||||
before: cursor,
|
||||
await this.rootStore.agent.listNotifications({
|
||||
cursor,
|
||||
limit: Math.min(numToFetch, 100),
|
||||
})
|
||||
if (res.data.notifications.length === 0) {
|
||||
|
@ -390,7 +392,7 @@ export class NotificationsViewModel {
|
|||
*/
|
||||
loadUnreadCount = bundleAsync(async () => {
|
||||
const old = this.unreadCount
|
||||
const res = await this.rootStore.api.app.bsky.notification.getCount()
|
||||
const res = await this.rootStore.agent.countUnreadNotifications()
|
||||
runInAction(() => {
|
||||
this.unreadCount = res.data.count
|
||||
})
|
||||
|
@ -408,9 +410,7 @@ export class NotificationsViewModel {
|
|||
for (const notif of this.notifications) {
|
||||
notif.isRead = true
|
||||
}
|
||||
await this.rootStore.api.app.bsky.notification.updateSeen({
|
||||
seenAt: new Date().toISOString(),
|
||||
})
|
||||
await this.rootStore.agent.updateSeenNotifications()
|
||||
} catch (e: any) {
|
||||
this.rootStore.log.warn('Failed to update notifications read state', e)
|
||||
}
|
||||
|
@ -418,7 +418,7 @@ export class NotificationsViewModel {
|
|||
|
||||
async getNewMostRecent(): Promise<NotificationsViewItemModel | undefined> {
|
||||
let old = this.mostRecentNotificationUri
|
||||
const res = await this.rootStore.api.app.bsky.notification.list({
|
||||
const res = await this.rootStore.agent.listNotifications({
|
||||
limit: 1,
|
||||
})
|
||||
if (!res.data.notifications[0] || old === res.data.notifications[0].uri) {
|
||||
|
@ -437,13 +437,13 @@ export class NotificationsViewModel {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
|
@ -456,14 +456,14 @@ export class NotificationsViewModel {
|
|||
// helper functions
|
||||
// =
|
||||
|
||||
private async _replaceAll(res: ListNotifications.Response) {
|
||||
async _replaceAll(res: ListNotifications.Response) {
|
||||
if (res.data.notifications[0]) {
|
||||
this.mostRecentNotificationUri = res.data.notifications[0].uri
|
||||
}
|
||||
return this._appendAll(res, true)
|
||||
}
|
||||
|
||||
private async _appendAll(res: ListNotifications.Response, replace = false) {
|
||||
async _appendAll(res: ListNotifications.Response, replace = false) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
const promises = []
|
||||
|
@ -494,7 +494,7 @@ export class NotificationsViewModel {
|
|||
})
|
||||
}
|
||||
|
||||
private async _prependAll(res: ListNotifications.Response) {
|
||||
async _prependAll(res: ListNotifications.Response) {
|
||||
const promises = []
|
||||
const itemModels: NotificationsViewItemModel[] = []
|
||||
const dedupedNotifs = res.data.notifications.filter(
|
||||
|
@ -525,7 +525,7 @@ export class NotificationsViewModel {
|
|||
})
|
||||
}
|
||||
|
||||
private _updateAll(res: ListNotifications.Response) {
|
||||
_updateAll(res: ListNotifications.Response) {
|
||||
for (const item of res.data.notifications) {
|
||||
const existingItem = this.notifications.find(item2 => isEq(item, item2))
|
||||
if (existingItem) {
|
||||
|
|
|
@ -2,12 +2,13 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
|||
import {
|
||||
AppBskyFeedGetPostThread as GetPostThread,
|
||||
AppBskyFeedPost as FeedPost,
|
||||
AppBskyFeedDefs,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {RichText} from 'lib/strings/rich-text'
|
||||
|
||||
function* reactKeyGenerator(): Generator<string> {
|
||||
let counter = 0
|
||||
|
@ -26,10 +27,10 @@ export class PostThreadViewPostModel {
|
|||
_hasMore = false
|
||||
|
||||
// data
|
||||
post: FeedPost.View
|
||||
post: AppBskyFeedDefs.PostView
|
||||
postRecord?: FeedPost.Record
|
||||
parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost
|
||||
replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[]
|
||||
parent?: PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost
|
||||
replies?: (PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost)[]
|
||||
richText?: RichText
|
||||
|
||||
get uri() {
|
||||
|
@ -43,7 +44,7 @@ export class PostThreadViewPostModel {
|
|||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
reactKey: string,
|
||||
v: GetPostThread.ThreadViewPost,
|
||||
v: AppBskyFeedDefs.ThreadViewPost,
|
||||
) {
|
||||
this._reactKey = reactKey
|
||||
this.post = v.post
|
||||
|
@ -51,11 +52,7 @@ export class PostThreadViewPostModel {
|
|||
const valid = FeedPost.validateRecord(this.post.record)
|
||||
if (valid.success) {
|
||||
this.postRecord = this.post.record
|
||||
this.richText = new RichText(
|
||||
this.postRecord.text,
|
||||
this.postRecord.entities,
|
||||
{cleanNewlines: true},
|
||||
)
|
||||
this.richText = new RichText(this.postRecord, {cleanNewlines: true})
|
||||
} else {
|
||||
rootStore.log.warn(
|
||||
'Received an invalid app.bsky.feed.post record',
|
||||
|
@ -74,14 +71,14 @@ export class PostThreadViewPostModel {
|
|||
|
||||
assignTreeModels(
|
||||
keyGen: Generator<string>,
|
||||
v: GetPostThread.ThreadViewPost,
|
||||
v: AppBskyFeedDefs.ThreadViewPost,
|
||||
higlightedPostUri: string,
|
||||
includeParent = true,
|
||||
includeChildren = true,
|
||||
) {
|
||||
// parents
|
||||
if (includeParent && v.parent) {
|
||||
if (GetPostThread.isThreadViewPost(v.parent)) {
|
||||
if (AppBskyFeedDefs.isThreadViewPost(v.parent)) {
|
||||
const parentModel = new PostThreadViewPostModel(
|
||||
this.rootStore,
|
||||
keyGen.next().value,
|
||||
|
@ -100,7 +97,7 @@ export class PostThreadViewPostModel {
|
|||
)
|
||||
}
|
||||
this.parent = parentModel
|
||||
} else if (GetPostThread.isNotFoundPost(v.parent)) {
|
||||
} else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
|
||||
this.parent = v.parent
|
||||
}
|
||||
}
|
||||
|
@ -108,7 +105,7 @@ export class PostThreadViewPostModel {
|
|||
if (includeChildren && v.replies) {
|
||||
const replies = []
|
||||
for (const item of v.replies) {
|
||||
if (GetPostThread.isThreadViewPost(item)) {
|
||||
if (AppBskyFeedDefs.isThreadViewPost(item)) {
|
||||
const itemModel = new PostThreadViewPostModel(
|
||||
this.rootStore,
|
||||
keyGen.next().value,
|
||||
|
@ -128,7 +125,7 @@ export class PostThreadViewPostModel {
|
|||
)
|
||||
}
|
||||
replies.push(itemModel)
|
||||
} else if (GetPostThread.isNotFoundPost(item)) {
|
||||
} else if (AppBskyFeedDefs.isNotFoundPost(item)) {
|
||||
replies.push(item)
|
||||
}
|
||||
}
|
||||
|
@ -136,68 +133,43 @@ export class PostThreadViewPostModel {
|
|||
}
|
||||
}
|
||||
|
||||
async toggleUpvote() {
|
||||
const wasUpvoted = !!this.post.viewer.upvote
|
||||
const wasDownvoted = !!this.post.viewer.downvote
|
||||
const res = await this.rootStore.api.app.bsky.feed.setVote({
|
||||
subject: {
|
||||
uri: this.post.uri,
|
||||
cid: this.post.cid,
|
||||
},
|
||||
direction: wasUpvoted ? 'none' : 'up',
|
||||
})
|
||||
async toggleLike() {
|
||||
if (this.post.viewer?.like) {
|
||||
await this.rootStore.agent.deleteLike(this.post.viewer.like)
|
||||
runInAction(() => {
|
||||
if (wasDownvoted) {
|
||||
this.post.downvoteCount--
|
||||
}
|
||||
if (wasUpvoted) {
|
||||
this.post.upvoteCount--
|
||||
this.post.likeCount = this.post.likeCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.likeCount--
|
||||
this.post.viewer.like = undefined
|
||||
})
|
||||
} else {
|
||||
this.post.upvoteCount++
|
||||
}
|
||||
this.post.viewer.upvote = res.data.upvote
|
||||
this.post.viewer.downvote = res.data.downvote
|
||||
})
|
||||
}
|
||||
|
||||
async toggleDownvote() {
|
||||
const wasUpvoted = !!this.post.viewer.upvote
|
||||
const wasDownvoted = !!this.post.viewer.downvote
|
||||
const res = await this.rootStore.api.app.bsky.feed.setVote({
|
||||
subject: {
|
||||
uri: this.post.uri,
|
||||
cid: this.post.cid,
|
||||
},
|
||||
direction: wasDownvoted ? 'none' : 'down',
|
||||
})
|
||||
const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
|
||||
runInAction(() => {
|
||||
if (wasUpvoted) {
|
||||
this.post.upvoteCount--
|
||||
}
|
||||
if (wasDownvoted) {
|
||||
this.post.downvoteCount--
|
||||
} else {
|
||||
this.post.downvoteCount++
|
||||
}
|
||||
this.post.viewer.upvote = res.data.upvote
|
||||
this.post.viewer.downvote = res.data.downvote
|
||||
this.post.likeCount = this.post.likeCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.likeCount++
|
||||
this.post.viewer.like = res.uri
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async toggleRepost() {
|
||||
if (this.post.viewer.repost) {
|
||||
await apilib.unrepost(this.rootStore, this.post.viewer.repost)
|
||||
if (this.post.viewer?.repost) {
|
||||
await this.rootStore.agent.deleteRepost(this.post.viewer.repost)
|
||||
runInAction(() => {
|
||||
this.post.repostCount = this.post.repostCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.repostCount--
|
||||
this.post.viewer.repost = undefined
|
||||
})
|
||||
} else {
|
||||
const res = await apilib.repost(
|
||||
this.rootStore,
|
||||
const res = await this.rootStore.agent.repost(
|
||||
this.post.uri,
|
||||
this.post.cid,
|
||||
)
|
||||
runInAction(() => {
|
||||
this.post.repostCount = this.post.repostCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.repostCount++
|
||||
this.post.viewer.repost = res.uri
|
||||
})
|
||||
|
@ -205,10 +177,7 @@ export class PostThreadViewPostModel {
|
|||
}
|
||||
|
||||
async delete() {
|
||||
await this.rootStore.api.app.bsky.feed.post.delete({
|
||||
did: this.post.author.did,
|
||||
rkey: new AtUri(this.post.uri).rkey,
|
||||
})
|
||||
await this.rootStore.agent.deletePost(this.post.uri)
|
||||
this.rootStore.emitPostDeleted(this.post.uri)
|
||||
}
|
||||
}
|
||||
|
@ -301,14 +270,14 @@ export class PostThreadViewModel {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
this.notFound = false
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
|
@ -322,7 +291,7 @@ export class PostThreadViewModel {
|
|||
// loader functions
|
||||
// =
|
||||
|
||||
private async _resolveUri() {
|
||||
async _resolveUri() {
|
||||
const urip = new AtUri(this.params.uri)
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
try {
|
||||
|
@ -336,10 +305,10 @@ export class PostThreadViewModel {
|
|||
})
|
||||
}
|
||||
|
||||
private async _load(isRefreshing = false) {
|
||||
async _load(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = await this.rootStore.api.app.bsky.feed.getPostThread(
|
||||
const res = await this.rootStore.agent.getPostThread(
|
||||
Object.assign({}, this.params, {uri: this.resolvedUri}),
|
||||
)
|
||||
this._replaceAll(res)
|
||||
|
@ -349,18 +318,18 @@ export class PostThreadViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: GetPostThread.Response) {
|
||||
_replaceAll(res: GetPostThread.Response) {
|
||||
sortThread(res.data.thread)
|
||||
const keyGen = reactKeyGenerator()
|
||||
const thread = new PostThreadViewPostModel(
|
||||
this.rootStore,
|
||||
keyGen.next().value,
|
||||
res.data.thread as GetPostThread.ThreadViewPost,
|
||||
res.data.thread as AppBskyFeedDefs.ThreadViewPost,
|
||||
)
|
||||
thread._isHighlightedPost = true
|
||||
thread.assignTreeModels(
|
||||
keyGen,
|
||||
res.data.thread as GetPostThread.ThreadViewPost,
|
||||
res.data.thread as AppBskyFeedDefs.ThreadViewPost,
|
||||
thread.uri,
|
||||
)
|
||||
this.thread = thread
|
||||
|
@ -368,25 +337,25 @@ export class PostThreadViewModel {
|
|||
}
|
||||
|
||||
type MaybePost =
|
||||
| GetPostThread.ThreadViewPost
|
||||
| GetPostThread.NotFoundPost
|
||||
| AppBskyFeedDefs.ThreadViewPost
|
||||
| AppBskyFeedDefs.NotFoundPost
|
||||
| {[k: string]: unknown; $type: string}
|
||||
function sortThread(post: MaybePost) {
|
||||
if (post.notFound) {
|
||||
return
|
||||
}
|
||||
post = post as GetPostThread.ThreadViewPost
|
||||
post = post as AppBskyFeedDefs.ThreadViewPost
|
||||
if (post.replies) {
|
||||
post.replies.sort((a: MaybePost, b: MaybePost) => {
|
||||
post = post as GetPostThread.ThreadViewPost
|
||||
post = post as AppBskyFeedDefs.ThreadViewPost
|
||||
if (a.notFound) {
|
||||
return 1
|
||||
}
|
||||
if (b.notFound) {
|
||||
return -1
|
||||
}
|
||||
a = a as GetPostThread.ThreadViewPost
|
||||
b = b as GetPostThread.ThreadViewPost
|
||||
a = a as AppBskyFeedDefs.ThreadViewPost
|
||||
b = b as AppBskyFeedDefs.ThreadViewPost
|
||||
const aIsByOp = a.post.author.did === post.post.author.did
|
||||
const bIsByOp = b.post.author.did === post.post.author.did
|
||||
if (aIsByOp && bIsByOp) {
|
||||
|
|
|
@ -58,12 +58,12 @@ export class PostModel implements RemoveIndex<Post.Record> {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading() {
|
||||
_xLoading() {
|
||||
this.isLoading = true
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
|
@ -75,12 +75,12 @@ export class PostModel implements RemoveIndex<Post.Record> {
|
|||
// loader functions
|
||||
// =
|
||||
|
||||
private async _load() {
|
||||
async _load() {
|
||||
this._xLoading()
|
||||
try {
|
||||
const urip = new AtUri(this.uri)
|
||||
const res = await this.rootStore.api.app.bsky.feed.post.get({
|
||||
user: urip.host,
|
||||
const res = await this.rootStore.agent.getPost({
|
||||
repo: urip.host,
|
||||
rkey: urip.rkey,
|
||||
})
|
||||
// TODO
|
||||
|
@ -94,7 +94,7 @@ export class PostModel implements RemoveIndex<Post.Record> {
|
|||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: Post.Record) {
|
||||
_replaceAll(res: Post.Record) {
|
||||
this.text = res.text
|
||||
this.entities = res.entities
|
||||
this.reply = res.reply
|
||||
|
|
|
@ -2,15 +2,12 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
|||
import {PickedMedia} from 'lib/media/picker'
|
||||
import {
|
||||
AppBskyActorGetProfile as GetProfile,
|
||||
AppBskySystemDeclRef,
|
||||
AppBskyActorUpdateProfile,
|
||||
AppBskyActorProfile,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
type DeclRef = AppBskySystemDeclRef.Main
|
||||
import {extractEntities} from 'lib/strings/rich-text-detection'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {RichText} from 'lib/strings/rich-text'
|
||||
|
||||
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
|
||||
|
||||
|
@ -35,22 +32,18 @@ export class ProfileViewModel {
|
|||
// data
|
||||
did: string = ''
|
||||
handle: string = ''
|
||||
declaration: DeclRef = {
|
||||
cid: '',
|
||||
actorType: '',
|
||||
}
|
||||
creator: string = ''
|
||||
displayName?: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
banner?: string
|
||||
displayName?: string = ''
|
||||
description?: string = ''
|
||||
avatar?: string = ''
|
||||
banner?: string = ''
|
||||
followersCount: number = 0
|
||||
followsCount: number = 0
|
||||
postsCount: number = 0
|
||||
viewer = new ProfileViewViewerModel()
|
||||
|
||||
// added data
|
||||
descriptionRichText?: RichText
|
||||
descriptionRichText?: RichText = new RichText({text: ''})
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
|
@ -79,10 +72,6 @@ export class ProfileViewModel {
|
|||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get isUser() {
|
||||
return this.declaration.actorType === ACTOR_TYPE_USER
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
|
@ -111,18 +100,14 @@ export class ProfileViewModel {
|
|||
}
|
||||
|
||||
if (followUri) {
|
||||
await apilib.unfollow(this.rootStore, followUri)
|
||||
await this.rootStore.agent.deleteFollow(followUri)
|
||||
runInAction(() => {
|
||||
this.followersCount--
|
||||
this.viewer.following = undefined
|
||||
this.rootStore.me.follows.removeFollow(this.did)
|
||||
})
|
||||
} else {
|
||||
const res = await apilib.follow(
|
||||
this.rootStore,
|
||||
this.did,
|
||||
this.declaration.cid,
|
||||
)
|
||||
const res = await this.rootStore.agent.follow(this.did)
|
||||
runInAction(() => {
|
||||
this.followersCount++
|
||||
this.viewer.following = res.uri
|
||||
|
@ -132,22 +117,23 @@ export class ProfileViewModel {
|
|||
}
|
||||
|
||||
async updateProfile(
|
||||
updates: AppBskyActorUpdateProfile.InputSchema,
|
||||
updates: AppBskyActorProfile.Record,
|
||||
newUserAvatar: PickedMedia | undefined | null,
|
||||
newUserBanner: PickedMedia | undefined | null,
|
||||
) {
|
||||
await this.rootStore.agent.upsertProfile(async existing => {
|
||||
existing = existing || {}
|
||||
existing.displayName = updates.displayName
|
||||
existing.description = updates.description
|
||||
if (newUserAvatar) {
|
||||
const res = await apilib.uploadBlob(
|
||||
this.rootStore,
|
||||
newUserAvatar.path,
|
||||
newUserAvatar.mime,
|
||||
)
|
||||
updates.avatar = {
|
||||
cid: res.data.cid,
|
||||
mimeType: newUserAvatar.mime,
|
||||
}
|
||||
existing.avatar = res.data.blob
|
||||
} else if (newUserAvatar === null) {
|
||||
updates.avatar = null
|
||||
existing.avatar = undefined
|
||||
}
|
||||
if (newUserBanner) {
|
||||
const res = await apilib.uploadBlob(
|
||||
|
@ -155,26 +141,24 @@ export class ProfileViewModel {
|
|||
newUserBanner.path,
|
||||
newUserBanner.mime,
|
||||
)
|
||||
updates.banner = {
|
||||
cid: res.data.cid,
|
||||
mimeType: newUserBanner.mime,
|
||||
}
|
||||
existing.banner = res.data.blob
|
||||
} else if (newUserBanner === null) {
|
||||
updates.banner = null
|
||||
existing.banner = undefined
|
||||
}
|
||||
await this.rootStore.api.app.bsky.actor.updateProfile(updates)
|
||||
return existing
|
||||
})
|
||||
await this.rootStore.me.load()
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
async muteAccount() {
|
||||
await this.rootStore.api.app.bsky.graph.mute({user: this.did})
|
||||
await this.rootStore.agent.mute(this.did)
|
||||
this.viewer.muted = true
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
async unmuteAccount() {
|
||||
await this.rootStore.api.app.bsky.graph.unmute({user: this.did})
|
||||
await this.rootStore.agent.unmute(this.did)
|
||||
this.viewer.muted = false
|
||||
await this.refresh()
|
||||
}
|
||||
|
@ -182,13 +166,13 @@ export class ProfileViewModel {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
|
@ -201,40 +185,40 @@ export class ProfileViewModel {
|
|||
// loader functions
|
||||
// =
|
||||
|
||||
private async _load(isRefreshing = false) {
|
||||
async _load(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = await this.rootStore.api.app.bsky.actor.getProfile(
|
||||
this.params,
|
||||
)
|
||||
const res = await this.rootStore.agent.getProfile(this.params)
|
||||
this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
|
||||
this._replaceAll(res)
|
||||
await this._createRichText()
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: GetProfile.Response) {
|
||||
_replaceAll(res: GetProfile.Response) {
|
||||
this.did = res.data.did
|
||||
this.handle = res.data.handle
|
||||
Object.assign(this.declaration, res.data.declaration)
|
||||
this.creator = res.data.creator
|
||||
this.displayName = res.data.displayName
|
||||
this.description = res.data.description
|
||||
this.avatar = res.data.avatar
|
||||
this.banner = res.data.banner
|
||||
this.followersCount = res.data.followersCount
|
||||
this.followsCount = res.data.followsCount
|
||||
this.postsCount = res.data.postsCount
|
||||
this.followersCount = res.data.followersCount || 0
|
||||
this.followsCount = res.data.followsCount || 0
|
||||
this.postsCount = res.data.postsCount || 0
|
||||
if (res.data.viewer) {
|
||||
Object.assign(this.viewer, res.data.viewer)
|
||||
this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
|
||||
}
|
||||
}
|
||||
|
||||
async _createRichText() {
|
||||
this.descriptionRichText = new RichText(
|
||||
this.description || '',
|
||||
extractEntities(this.description || ''),
|
||||
{text: this.description || ''},
|
||||
{cleanNewlines: true},
|
||||
)
|
||||
await this.descriptionRichText.detectFacets(this.rootStore.agent)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ export class ProfilesViewModel {
|
|||
}
|
||||
}
|
||||
try {
|
||||
const promise = this.rootStore.api.app.bsky.actor.getProfile({
|
||||
const promise = this.rootStore.agent.getProfile({
|
||||
actor: did,
|
||||
})
|
||||
this.cache.set(did, promise)
|
||||
|
|
|
@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
|||
import {AtUri} from '../../third-party/uri'
|
||||
import {
|
||||
AppBskyFeedGetRepostedBy as GetRepostedBy,
|
||||
AppBskyActorRef as ActorRef,
|
||||
AppBskyActorDefs,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
|
@ -11,7 +11,7 @@ import * as apilib from 'lib/api/index'
|
|||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export type RepostedByItem = ActorRef.WithInfo
|
||||
export type RepostedByItem = AppBskyActorDefs.ProfileViewBasic
|
||||
|
||||
export class RepostedByViewModel {
|
||||
// state
|
||||
|
@ -71,9 +71,9 @@ export class RepostedByViewModel {
|
|||
const params = Object.assign({}, this.params, {
|
||||
uri: this.resolvedUri,
|
||||
limit: PAGE_SIZE,
|
||||
before: replace ? undefined : this.loadMoreCursor,
|
||||
cursor: replace ? undefined : this.loadMoreCursor,
|
||||
})
|
||||
const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(params)
|
||||
const res = await this.rootStore.agent.getRepostedBy(params)
|
||||
if (replace) {
|
||||
this._replaceAll(res)
|
||||
} else {
|
||||
|
@ -88,13 +88,13 @@ export class RepostedByViewModel {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
|
@ -107,7 +107,7 @@ export class RepostedByViewModel {
|
|||
// helper functions
|
||||
// =
|
||||
|
||||
private async _resolveUri() {
|
||||
async _resolveUri() {
|
||||
const urip = new AtUri(this.params.uri)
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
try {
|
||||
|
@ -121,12 +121,12 @@ export class RepostedByViewModel {
|
|||
})
|
||||
}
|
||||
|
||||
private _replaceAll(res: GetRepostedBy.Response) {
|
||||
_replaceAll(res: GetRepostedBy.Response) {
|
||||
this.repostedBy = []
|
||||
this._appendAll(res)
|
||||
}
|
||||
|
||||
private _appendAll(res: GetRepostedBy.Response) {
|
||||
_appendAll(res: GetRepostedBy.Response) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
* The root store is the base of all modeled state.
|
||||
*/
|
||||
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {createContext, useContext} from 'react'
|
||||
import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
|
||||
import * as BgScheduler from 'lib/bg-scheduler'
|
||||
|
@ -29,7 +29,7 @@ export const appInfo = z.object({
|
|||
export type AppInfo = z.infer<typeof appInfo>
|
||||
|
||||
export class RootStoreModel {
|
||||
agent: AtpAgent
|
||||
agent: BskyAgent
|
||||
appInfo?: AppInfo
|
||||
log = new LogModel()
|
||||
session = new SessionModel(this)
|
||||
|
@ -40,41 +40,16 @@ export class RootStoreModel {
|
|||
linkMetas = new LinkMetasCache(this)
|
||||
imageSizes = new ImageSizesCache()
|
||||
|
||||
// HACK
|
||||
// this flag is to track the lexicon breaking refactor
|
||||
// it should be removed once we get that done
|
||||
// -prf
|
||||
hackUpgradeNeeded = false
|
||||
async hackCheckIfUpgradeNeeded() {
|
||||
try {
|
||||
this.log.debug('hackCheckIfUpgradeNeeded()')
|
||||
const res = await fetch('https://bsky.social/xrpc/app.bsky.feed.getLikes')
|
||||
await res.text()
|
||||
runInAction(() => {
|
||||
this.hackUpgradeNeeded = res.status !== 501
|
||||
this.log.debug(
|
||||
`hackCheckIfUpgradeNeeded() said ${this.hackUpgradeNeeded}`,
|
||||
)
|
||||
})
|
||||
} catch (e) {
|
||||
this.log.error('Failed to hackCheckIfUpgradeNeeded', {e})
|
||||
}
|
||||
}
|
||||
|
||||
constructor(agent: AtpAgent) {
|
||||
constructor(agent: BskyAgent) {
|
||||
this.agent = agent
|
||||
makeAutoObservable(this, {
|
||||
api: false,
|
||||
agent: false,
|
||||
serialize: false,
|
||||
hydrate: false,
|
||||
})
|
||||
this.initBgFetch()
|
||||
}
|
||||
|
||||
get api() {
|
||||
return this.agent.api
|
||||
}
|
||||
|
||||
setAppInfo(info: AppInfo) {
|
||||
this.appInfo = info
|
||||
}
|
||||
|
@ -131,7 +106,7 @@ export class RootStoreModel {
|
|||
/**
|
||||
* Called by the session model. Refreshes session-oriented state.
|
||||
*/
|
||||
async handleSessionChange(agent: AtpAgent) {
|
||||
async handleSessionChange(agent: BskyAgent) {
|
||||
this.log.debug('RootStoreModel:handleSessionChange')
|
||||
this.agent = agent
|
||||
this.me.clear()
|
||||
|
@ -259,7 +234,7 @@ export class RootStoreModel {
|
|||
async onBgFetch(taskId: string) {
|
||||
this.log.debug(`Background fetch fired for task ${taskId}`)
|
||||
if (this.session.hasSession) {
|
||||
const res = await this.api.app.bsky.notification.getCount()
|
||||
const res = await this.agent.countUnreadNotifications()
|
||||
const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count
|
||||
this.emitUnreadNotifications(res.data.count)
|
||||
this.log.debug(
|
||||
|
@ -286,7 +261,7 @@ export class RootStoreModel {
|
|||
}
|
||||
|
||||
const throwawayInst = new RootStoreModel(
|
||||
new AtpAgent({service: 'http://localhost'}),
|
||||
new BskyAgent({service: 'http://localhost'}),
|
||||
) // this will be replaced by the loader, we just need to supply a value at init
|
||||
const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
|
||||
export const RootStoreProvider = RootStoreContext.Provider
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {
|
||||
AtpAgent,
|
||||
BskyAgent,
|
||||
AtpSessionEvent,
|
||||
AtpSessionData,
|
||||
ComAtprotoServerGetAccountsConfig as GetAccountsConfig,
|
||||
ComAtprotoServerDescribeServer as DescribeServer,
|
||||
} from '@atproto/api'
|
||||
import normalizeUrl from 'normalize-url'
|
||||
import {isObj, hasProp} from 'lib/type-guards'
|
||||
|
@ -11,7 +11,7 @@ import {networkRetry} from 'lib/async/retry'
|
|||
import {z} from 'zod'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
export type ServiceDescription = GetAccountsConfig.OutputSchema
|
||||
export type ServiceDescription = DescribeServer.OutputSchema
|
||||
|
||||
export const activeSession = z.object({
|
||||
service: z.string(),
|
||||
|
@ -40,7 +40,7 @@ export class SessionModel {
|
|||
// emergency log facility to help us track down this logout issue
|
||||
// remove when resolved
|
||||
// -prf
|
||||
private _log(message: string, details?: Record<string, any>) {
|
||||
_log(message: string, details?: Record<string, any>) {
|
||||
details = details || {}
|
||||
details.state = {
|
||||
data: this.data,
|
||||
|
@ -73,6 +73,7 @@ export class SessionModel {
|
|||
rootStore: false,
|
||||
serialize: false,
|
||||
hydrate: false,
|
||||
hasSession: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -154,7 +155,7 @@ export class SessionModel {
|
|||
/**
|
||||
* Sets the active session
|
||||
*/
|
||||
async setActiveSession(agent: AtpAgent, did: string) {
|
||||
async setActiveSession(agent: BskyAgent, did: string) {
|
||||
this._log('SessionModel:setActiveSession')
|
||||
this.data = {
|
||||
service: agent.service.toString(),
|
||||
|
@ -166,7 +167,7 @@ export class SessionModel {
|
|||
/**
|
||||
* Upserts a session into the accounts
|
||||
*/
|
||||
private persistSession(
|
||||
persistSession(
|
||||
service: string,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
|
@ -225,7 +226,7 @@ export class SessionModel {
|
|||
/**
|
||||
* Clears any session tokens from the accounts; used on logout.
|
||||
*/
|
||||
private clearSessionTokens() {
|
||||
clearSessionTokens() {
|
||||
this._log('SessionModel:clearSessionTokens')
|
||||
this.accounts = this.accounts.map(acct => ({
|
||||
service: acct.service,
|
||||
|
@ -239,10 +240,8 @@ export class SessionModel {
|
|||
/**
|
||||
* Fetches additional information about an account on load.
|
||||
*/
|
||||
private async loadAccountInfo(agent: AtpAgent, did: string) {
|
||||
const res = await agent.api.app.bsky.actor
|
||||
.getProfile({actor: did})
|
||||
.catch(_e => undefined)
|
||||
async loadAccountInfo(agent: BskyAgent, did: string) {
|
||||
const res = await agent.getProfile({actor: did}).catch(_e => undefined)
|
||||
if (res) {
|
||||
return {
|
||||
dispayName: res.data.displayName,
|
||||
|
@ -255,8 +254,8 @@ export class SessionModel {
|
|||
* Helper to fetch the accounts config settings from an account.
|
||||
*/
|
||||
async describeService(service: string): Promise<ServiceDescription> {
|
||||
const agent = new AtpAgent({service})
|
||||
const res = await agent.api.com.atproto.server.getAccountsConfig({})
|
||||
const agent = new BskyAgent({service})
|
||||
const res = await agent.com.atproto.server.describeServer({})
|
||||
return res.data
|
||||
}
|
||||
|
||||
|
@ -272,7 +271,7 @@ export class SessionModel {
|
|||
return false
|
||||
}
|
||||
|
||||
const agent = new AtpAgent({
|
||||
const agent = new BskyAgent({
|
||||
service: account.service,
|
||||
persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
|
||||
this.persistSession(account.service, account.did, evt, sess)
|
||||
|
@ -321,7 +320,7 @@ export class SessionModel {
|
|||
password: string
|
||||
}) {
|
||||
this._log('SessionModel:login')
|
||||
const agent = new AtpAgent({service})
|
||||
const agent = new BskyAgent({service})
|
||||
await agent.login({identifier, password})
|
||||
if (!agent.session) {
|
||||
throw new Error('Failed to establish session')
|
||||
|
@ -355,7 +354,7 @@ export class SessionModel {
|
|||
inviteCode?: string
|
||||
}) {
|
||||
this._log('SessionModel:createAccount')
|
||||
const agent = new AtpAgent({service})
|
||||
const agent = new BskyAgent({service})
|
||||
await agent.createAccount({
|
||||
handle,
|
||||
password,
|
||||
|
@ -389,7 +388,7 @@ export class SessionModel {
|
|||
// need to evaluate why deleting the session has caused errors at times
|
||||
// -prf
|
||||
/*if (this.hasSession) {
|
||||
this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
|
||||
this.rootStore.agent.com.atproto.session.delete().catch((e: any) => {
|
||||
this.rootStore.log.warn(
|
||||
'(Minor issue) Failed to delete session on the server',
|
||||
e,
|
||||
|
@ -415,7 +414,7 @@ export class SessionModel {
|
|||
if (!sess) {
|
||||
return
|
||||
}
|
||||
const res = await this.rootStore.api.app.bsky.actor
|
||||
const res = await this.rootStore.agent
|
||||
.getProfile({actor: sess.did})
|
||||
.catch(_e => undefined)
|
||||
if (res?.success) {
|
||||
|
|
|
@ -72,12 +72,12 @@ export class SuggestedPostsView {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading() {
|
||||
_xLoading() {
|
||||
this.isLoading = true
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
|
|
|
@ -2,7 +2,7 @@ import {makeAutoObservable} from 'mobx'
|
|||
import {RootStoreModel} from '../root-store'
|
||||
import {ServiceDescription} from '../session'
|
||||
import {DEFAULT_SERVICE} from 'state/index'
|
||||
import {ComAtprotoAccountCreate} from '@atproto/api'
|
||||
import {ComAtprotoServerCreateAccount} from '@atproto/api'
|
||||
import * as EmailValidator from 'email-validator'
|
||||
import {createFullHandle} from 'lib/strings/handles'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
@ -99,7 +99,7 @@ export class CreateAccountModel {
|
|||
})
|
||||
} catch (e: any) {
|
||||
let errMsg = e.toString()
|
||||
if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) {
|
||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
||||
errMsg =
|
||||
'Invite code not accepted. Check that you input it correctly and try again.'
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ export class ProfileUiModel {
|
|||
)
|
||||
this.profile = new ProfileViewModel(rootStore, {actor: params.user})
|
||||
this.feed = new FeedModel(rootStore, 'author', {
|
||||
author: params.user,
|
||||
actor: params.user,
|
||||
limit: 10,
|
||||
})
|
||||
}
|
||||
|
@ -64,16 +64,8 @@ export class ProfileUiModel {
|
|||
return this.profile.isRefreshing || this.currentView.isRefreshing
|
||||
}
|
||||
|
||||
get isUser() {
|
||||
return this.profile.isUser
|
||||
}
|
||||
|
||||
get selectorItems() {
|
||||
if (this.isUser) {
|
||||
return USER_SELECTOR_ITEMS
|
||||
} else {
|
||||
return USER_SELECTOR_ITEMS
|
||||
}
|
||||
}
|
||||
|
||||
get selectedView() {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {searchProfiles, searchPosts} from 'lib/api/search'
|
||||
import {AppBskyActorProfile as Profile} from '@atproto/api'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
|
||||
export class SearchUIModel {
|
||||
|
@ -8,7 +8,7 @@ export class SearchUIModel {
|
|||
isProfilesLoading = false
|
||||
query: string = ''
|
||||
postUris: string[] = []
|
||||
profiles: Profile.View[] = []
|
||||
profiles: AppBskyActorDefs.ProfileView[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(this)
|
||||
|
@ -34,10 +34,10 @@ export class SearchUIModel {
|
|||
this.isPostsLoading = false
|
||||
})
|
||||
|
||||
let profiles: Profile.View[] = []
|
||||
let profiles: AppBskyActorDefs.ProfileView[] = []
|
||||
if (profilesSearch?.length) {
|
||||
do {
|
||||
const res = await this.rootStore.api.app.bsky.actor.getProfiles({
|
||||
const res = await this.rootStore.agent.getProfiles({
|
||||
actors: profilesSearch.splice(0, 25).map(p => p.did),
|
||||
})
|
||||
profiles = profiles.concat(res.data.profiles)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {ProfileViewModel} from '../profile-view'
|
||||
|
@ -111,6 +112,7 @@ export interface ComposerOptsQuote {
|
|||
displayName?: string
|
||||
avatar?: string
|
||||
}
|
||||
embeds?: AppBskyEmbedRecord.ViewRecord['embeds']
|
||||
}
|
||||
export interface ComposerOpts {
|
||||
replyTo?: ComposerOptsPostRef
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {AppBskyActorRef} from '@atproto/api'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import AwaitLock from 'await-lock'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
|
@ -11,8 +11,8 @@ export class UserAutocompleteViewModel {
|
|||
lock = new AwaitLock()
|
||||
|
||||
// data
|
||||
follows: AppBskyActorRef.WithInfo[] = []
|
||||
searchRes: AppBskyActorRef.WithInfo[] = []
|
||||
follows: AppBskyActorDefs.ProfileViewBasic[] = []
|
||||
searchRes: AppBskyActorDefs.ProfileViewBasic[] = []
|
||||
knownHandles: Set<string> = new Set()
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
|
@ -76,9 +76,9 @@ export class UserAutocompleteViewModel {
|
|||
// internal
|
||||
// =
|
||||
|
||||
private async _getFollows() {
|
||||
const res = await this.rootStore.api.app.bsky.graph.getFollows({
|
||||
user: this.rootStore.me.did || '',
|
||||
async _getFollows() {
|
||||
const res = await this.rootStore.agent.getFollows({
|
||||
actor: this.rootStore.me.did || '',
|
||||
})
|
||||
runInAction(() => {
|
||||
this.follows = res.data.follows
|
||||
|
@ -88,13 +88,13 @@ export class UserAutocompleteViewModel {
|
|||
})
|
||||
}
|
||||
|
||||
private async _search() {
|
||||
const res = await this.rootStore.api.app.bsky.actor.searchTypeahead({
|
||||
async _search() {
|
||||
const res = await this.rootStore.agent.searchActorsTypeahead({
|
||||
term: this.prefix,
|
||||
limit: 8,
|
||||
})
|
||||
runInAction(() => {
|
||||
this.searchRes = res.data.users
|
||||
this.searchRes = res.data.actors
|
||||
for (const u of this.searchRes) {
|
||||
this.knownHandles.add(u.handle)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {
|
||||
AppBskyGraphGetFollowers as GetFollowers,
|
||||
AppBskyActorRef as ActorRef,
|
||||
AppBskyActorDefs as ActorDefs,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
@ -9,7 +9,7 @@ import {bundleAsync} from 'lib/async/bundle'
|
|||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export type FollowerItem = ActorRef.WithInfo
|
||||
export type FollowerItem = ActorDefs.ProfileViewBasic
|
||||
|
||||
export class UserFollowersViewModel {
|
||||
// state
|
||||
|
@ -22,10 +22,9 @@ export class UserFollowersViewModel {
|
|||
loadMoreCursor?: string
|
||||
|
||||
// data
|
||||
subject: ActorRef.WithInfo = {
|
||||
subject: ActorDefs.ProfileViewBasic = {
|
||||
did: '',
|
||||
handle: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
}
|
||||
followers: FollowerItem[] = []
|
||||
|
||||
|
@ -71,9 +70,9 @@ export class UserFollowersViewModel {
|
|||
try {
|
||||
const params = Object.assign({}, this.params, {
|
||||
limit: PAGE_SIZE,
|
||||
before: replace ? undefined : this.loadMoreCursor,
|
||||
cursor: replace ? undefined : this.loadMoreCursor,
|
||||
})
|
||||
const res = await this.rootStore.api.app.bsky.graph.getFollowers(params)
|
||||
const res = await this.rootStore.agent.getFollowers(params)
|
||||
if (replace) {
|
||||
this._replaceAll(res)
|
||||
} else {
|
||||
|
@ -88,13 +87,13 @@ export class UserFollowersViewModel {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
|
@ -107,12 +106,12 @@ export class UserFollowersViewModel {
|
|||
// helper functions
|
||||
// =
|
||||
|
||||
private _replaceAll(res: GetFollowers.Response) {
|
||||
_replaceAll(res: GetFollowers.Response) {
|
||||
this.followers = []
|
||||
this._appendAll(res)
|
||||
}
|
||||
|
||||
private _appendAll(res: GetFollowers.Response) {
|
||||
_appendAll(res: GetFollowers.Response) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
this.followers = this.followers.concat(res.data.followers)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {
|
||||
AppBskyGraphGetFollows as GetFollows,
|
||||
AppBskyActorRef as ActorRef,
|
||||
AppBskyActorDefs as ActorDefs,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
@ -9,7 +9,7 @@ import {bundleAsync} from 'lib/async/bundle'
|
|||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export type FollowItem = ActorRef.WithInfo
|
||||
export type FollowItem = ActorDefs.ProfileViewBasic
|
||||
|
||||
export class UserFollowsViewModel {
|
||||
// state
|
||||
|
@ -22,10 +22,9 @@ export class UserFollowsViewModel {
|
|||
loadMoreCursor?: string
|
||||
|
||||
// data
|
||||
subject: ActorRef.WithInfo = {
|
||||
subject: ActorDefs.ProfileViewBasic = {
|
||||
did: '',
|
||||
handle: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
}
|
||||
follows: FollowItem[] = []
|
||||
|
||||
|
@ -71,9 +70,9 @@ export class UserFollowsViewModel {
|
|||
try {
|
||||
const params = Object.assign({}, this.params, {
|
||||
limit: PAGE_SIZE,
|
||||
before: replace ? undefined : this.loadMoreCursor,
|
||||
cursor: replace ? undefined : this.loadMoreCursor,
|
||||
})
|
||||
const res = await this.rootStore.api.app.bsky.graph.getFollows(params)
|
||||
const res = await this.rootStore.agent.getFollows(params)
|
||||
if (replace) {
|
||||
this._replaceAll(res)
|
||||
} else {
|
||||
|
@ -88,13 +87,13 @@ export class UserFollowsViewModel {
|
|||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err?: any) {
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
|
@ -107,12 +106,12 @@ export class UserFollowsViewModel {
|
|||
// helper functions
|
||||
// =
|
||||
|
||||
private _replaceAll(res: GetFollows.Response) {
|
||||
_replaceAll(res: GetFollows.Response) {
|
||||
this.follows = []
|
||||
this._appendAll(res)
|
||||
}
|
||||
|
||||
private _appendAll(res: GetFollows.Response) {
|
||||
_appendAll(res: GetFollows.Response) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
this.follows = this.follows.concat(res.data.follows)
|
||||
|
|
|
@ -75,16 +75,14 @@ export const CreateAccount = observer(
|
|||
{model.step === 3 && <Step3 model={model} />}
|
||||
</View>
|
||||
<View style={[s.flexRow, s.pl20, s.pr20]}>
|
||||
<TouchableOpacity onPress={onPressBackInner}>
|
||||
<TouchableOpacity onPress={onPressBackInner} testID="backBtn">
|
||||
<Text type="xl" style={pal.link}>
|
||||
Back
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
{model.canNext ? (
|
||||
<TouchableOpacity
|
||||
testID="createAccountButton"
|
||||
onPress={onPressNext}>
|
||||
<TouchableOpacity testID="nextBtn" onPress={onPressNext}>
|
||||
{model.isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
|
@ -95,7 +93,7 @@ export const CreateAccount = observer(
|
|||
</TouchableOpacity>
|
||||
) : model.didServiceDescriptionFetchFail ? (
|
||||
<TouchableOpacity
|
||||
testID="registerRetryButton"
|
||||
testID="retryConnectBtn"
|
||||
onPress={onPressRetryConnect}>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
Retry
|
||||
|
|
|
@ -60,12 +60,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
|
|||
This is the company that keeps you online.
|
||||
</Text>
|
||||
<Option
|
||||
testID="blueskyServerBtn"
|
||||
isSelected={isDefaultSelected}
|
||||
label="Bluesky"
|
||||
help=" (default)"
|
||||
onPress={onPressDefault}
|
||||
/>
|
||||
<Option
|
||||
testID="otherServerBtn"
|
||||
isSelected={!isDefaultSelected}
|
||||
label="Other"
|
||||
onPress={onPressOther}>
|
||||
|
@ -74,6 +76,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
|
|||
Enter the address of your provider:
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="customServerInput"
|
||||
icon="globe"
|
||||
placeholder="Hosting provider address"
|
||||
value={model.serviceUrl}
|
||||
|
@ -83,12 +86,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
|
|||
{LOGIN_INCLUDE_DEV_SERVERS && (
|
||||
<View style={[s.flexRow, s.mt10]}>
|
||||
<Button
|
||||
testID="stagingServerBtn"
|
||||
type="default"
|
||||
style={s.mr5}
|
||||
label="Staging"
|
||||
onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)}
|
||||
/>
|
||||
<Button
|
||||
testID="localDevServerBtn"
|
||||
type="default"
|
||||
label="Dev Server"
|
||||
onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)}
|
||||
|
@ -112,11 +117,13 @@ function Option({
|
|||
label,
|
||||
help,
|
||||
onPress,
|
||||
testID,
|
||||
}: React.PropsWithChildren<{
|
||||
isSelected: boolean
|
||||
label: string
|
||||
help?: string
|
||||
onPress: () => void
|
||||
testID?: string
|
||||
}>) {
|
||||
const theme = useTheme()
|
||||
const pal = usePalette('default')
|
||||
|
@ -129,7 +136,7 @@ function Option({
|
|||
|
||||
return (
|
||||
<View style={[styles.option, pal.border]}>
|
||||
<TouchableWithoutFeedback onPress={onPress}>
|
||||
<TouchableWithoutFeedback onPress={onPress} testID={testID}>
|
||||
<View style={styles.optionHeading}>
|
||||
<View style={[styles.circle, pal.border]}>
|
||||
{isSelected ? (
|
||||
|
|
|
@ -59,6 +59,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
|
|||
Email address
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="emailInput"
|
||||
icon="envelope"
|
||||
placeholder="Enter your email address"
|
||||
value={model.email}
|
||||
|
@ -72,6 +73,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
|
|||
Password
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="passwordInput"
|
||||
icon="lock"
|
||||
placeholder="Choose your password"
|
||||
value={model.password}
|
||||
|
@ -86,7 +88,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
|
|||
Legal check
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
testID="registerIs13Input"
|
||||
testID="is13Input"
|
||||
style={[styles.toggleBtn, pal.border]}
|
||||
onPress={() => model.setIs13(!model.is13)}>
|
||||
<View style={[pal.borderDark, styles.checkbox]}>
|
||||
|
|
|
@ -17,6 +17,7 @@ export const Step3 = observer(({model}: {model: CreateAccountModel}) => {
|
|||
<StepHeader step="3" title="Your user handle" />
|
||||
<View style={s.pb10}>
|
||||
<TextInput
|
||||
testID="handleInput"
|
||||
icon="at"
|
||||
placeholder="eg alice"
|
||||
value={model.handle}
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import * as EmailValidator from 'email-validator'
|
||||
import AtpAgent from '@atproto/api'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {Text} from '../../util/text/Text'
|
||||
import {UserAvatar} from '../../util/UserAvatar'
|
||||
|
@ -506,8 +506,8 @@ const ForgotPasswordForm = ({
|
|||
setIsProcessing(true)
|
||||
|
||||
try {
|
||||
const agent = new AtpAgent({service: serviceUrl})
|
||||
await agent.api.com.atproto.account.requestPasswordReset({email})
|
||||
const agent = new BskyAgent({service: serviceUrl})
|
||||
await agent.com.atproto.server.requestPasswordReset({email})
|
||||
onEmailSent()
|
||||
} catch (e: any) {
|
||||
const errMsg = e.toString()
|
||||
|
@ -648,8 +648,8 @@ const SetNewPasswordForm = ({
|
|||
setIsProcessing(true)
|
||||
|
||||
try {
|
||||
const agent = new AtpAgent({service: serviceUrl})
|
||||
await agent.api.com.atproto.account.resetPassword({
|
||||
const agent = new BskyAgent({service: serviceUrl})
|
||||
await agent.com.atproto.server.resetPassword({
|
||||
token: resetCode,
|
||||
password,
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, {useEffect, useRef, useState} from 'react'
|
||||
import React from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
|
@ -13,6 +13,7 @@ import {
|
|||
} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {RichText} from '@atproto/api'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
|
@ -30,11 +31,11 @@ import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
|
|||
import {OpenCameraBtn} from './photos/OpenCameraBtn'
|
||||
import {SelectedPhotos} from './photos/SelectedPhotos'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed'
|
||||
import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
|
||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
const MAX_TEXT_LENGTH = 256
|
||||
const MAX_GRAPHEME_LENGTH = 300
|
||||
|
||||
export const ComposePost = observer(function ComposePost({
|
||||
replyTo,
|
||||
|
@ -50,17 +51,23 @@ export const ComposePost = observer(function ComposePost({
|
|||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const textInput = useRef<TextInputRef>(null)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [processingState, setProcessingState] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
||||
const textInput = React.useRef<TextInputRef>(null)
|
||||
const [isProcessing, setIsProcessing] = React.useState(false)
|
||||
const [processingState, setProcessingState] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
const [richtext, setRichText] = React.useState(new RichText({text: ''}))
|
||||
const graphemeLength = React.useMemo(
|
||||
() => richtext.graphemeLength,
|
||||
[richtext],
|
||||
)
|
||||
const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>(
|
||||
initQuote,
|
||||
)
|
||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
||||
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
|
||||
const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([])
|
||||
|
||||
const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
|
||||
() => new UserAutocompleteViewModel(store),
|
||||
|
@ -78,11 +85,11 @@ export const ComposePost = observer(function ComposePost({
|
|||
}, [textInput, onClose])
|
||||
|
||||
// initial setup
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
autocompleteView.setup()
|
||||
}, [autocompleteView])
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
// HACK
|
||||
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
|
||||
// -prf
|
||||
|
@ -132,18 +139,18 @@ export const ComposePost = observer(function ComposePost({
|
|||
if (isProcessing) {
|
||||
return
|
||||
}
|
||||
if (text.length > MAX_TEXT_LENGTH) {
|
||||
if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
if (text.trim().length === 0 && selectedPhotos.length === 0) {
|
||||
if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) {
|
||||
setError('Did you want to say anything?')
|
||||
return false
|
||||
}
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await apilib.post(store, {
|
||||
rawText: text,
|
||||
rawText: richtext.text,
|
||||
replyTo: replyTo?.uri,
|
||||
images: selectedPhotos,
|
||||
quote: quote,
|
||||
|
@ -172,7 +179,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
|
||||
}, [
|
||||
isProcessing,
|
||||
text,
|
||||
richtext,
|
||||
setError,
|
||||
setIsProcessing,
|
||||
replyTo,
|
||||
|
@ -187,7 +194,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
track,
|
||||
])
|
||||
|
||||
const canPost = text.length <= MAX_TEXT_LENGTH
|
||||
const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
|
||||
|
||||
const selectTextInputPlaceholder = replyTo
|
||||
? 'Write your reply'
|
||||
|
@ -215,7 +222,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
</View>
|
||||
) : canPost ? (
|
||||
<TouchableOpacity
|
||||
testID="composerPublishButton"
|
||||
testID="composerPublishBtn"
|
||||
onPress={onPressPublish}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
|
@ -271,42 +278,41 @@ export const ComposePost = observer(function ComposePost({
|
|||
<UserAvatar avatar={store.me.avatar} size={50} />
|
||||
<TextInput
|
||||
ref={textInput}
|
||||
text={text}
|
||||
richtext={richtext}
|
||||
placeholder={selectTextInputPlaceholder}
|
||||
suggestedLinks={suggestedLinks}
|
||||
autocompleteView={autocompleteView}
|
||||
onTextChanged={setText}
|
||||
setRichText={setRichText}
|
||||
onPhotoPasted={onPhotoPasted}
|
||||
onSuggestedLinksChanged={setSuggestedLinks}
|
||||
onError={setError}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{quote ? (
|
||||
<View style={s.mt5}>
|
||||
<QuoteEmbed quote={quote} />
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
<SelectedPhotos
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectPhotos={onSelectPhotos}
|
||||
/>
|
||||
{!selectedPhotos.length && extLink && (
|
||||
{selectedPhotos.length === 0 && extLink && (
|
||||
<ExternalEmbed
|
||||
link={extLink}
|
||||
onRemove={() => setExtLink(undefined)}
|
||||
/>
|
||||
)}
|
||||
{quote ? (
|
||||
<View style={s.mt5}>
|
||||
<QuoteEmbed quote={quote} />
|
||||
</View>
|
||||
) : undefined}
|
||||
</ScrollView>
|
||||
{!extLink &&
|
||||
selectedPhotos.length === 0 &&
|
||||
suggestedLinks.size > 0 &&
|
||||
!quote ? (
|
||||
suggestedLinks.size > 0 ? (
|
||||
<View style={s.mb5}>
|
||||
{Array.from(suggestedLinks).map(url => (
|
||||
<TouchableOpacity
|
||||
key={`suggested-${url}`}
|
||||
testID="addLinkCardBtn"
|
||||
style={[pal.borderDark, styles.addExtLinkBtn]}
|
||||
onPress={() => onPressAddLinkCard(url)}>
|
||||
<Text style={pal.text}>
|
||||
|
@ -318,17 +324,17 @@ export const ComposePost = observer(function ComposePost({
|
|||
) : null}
|
||||
<View style={[pal.border, styles.bottomBar]}>
|
||||
<SelectPhotoBtn
|
||||
enabled={!quote && selectedPhotos.length < 4}
|
||||
enabled={selectedPhotos.length < 4}
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectPhotos={setSelectedPhotos}
|
||||
/>
|
||||
<OpenCameraBtn
|
||||
enabled={!quote && selectedPhotos.length < 4}
|
||||
enabled={selectedPhotos.length < 4}
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectPhotos={setSelectedPhotos}
|
||||
/>
|
||||
<View style={s.flex1} />
|
||||
<CharProgress count={text.length} />
|
||||
<CharProgress count={graphemeLength} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</TouchableWithoutFeedback>
|
||||
|
@ -408,6 +414,7 @@ const styles = StyleSheet.create({
|
|||
borderRadius: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
marginHorizontal: 10,
|
||||
marginBottom: 4,
|
||||
},
|
||||
bottomBar: {
|
||||
|
|
|
@ -8,26 +8,24 @@ import ProgressPie from 'react-native-progress/Pie'
|
|||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
const MAX_TEXT_LENGTH = 256
|
||||
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
|
||||
const MAX_LENGTH = 300
|
||||
const DANGER_LENGTH = MAX_LENGTH
|
||||
|
||||
export function CharProgress({count}: {count: number}) {
|
||||
const pal = usePalette('default')
|
||||
const textColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.text
|
||||
const circleColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.link
|
||||
const textColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.text
|
||||
const circleColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.link
|
||||
return (
|
||||
<>
|
||||
<Text style={[s.mr10, {color: textColor}]}>
|
||||
{MAX_TEXT_LENGTH - count}
|
||||
</Text>
|
||||
<Text style={[s.mr10, {color: textColor}]}>{MAX_LENGTH - count}</Text>
|
||||
<View>
|
||||
{count > DANGER_TEXT_LENGTH ? (
|
||||
{count > DANGER_LENGTH ? (
|
||||
<ProgressPie
|
||||
size={30}
|
||||
borderWidth={4}
|
||||
borderColor={circleColor}
|
||||
color={circleColor}
|
||||
progress={Math.min((count - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH, 1)}
|
||||
progress={Math.min((count - MAX_LENGTH) / MAX_LENGTH, 1)}
|
||||
/>
|
||||
) : (
|
||||
<ProgressCircle
|
||||
|
@ -35,7 +33,7 @@ export function CharProgress({count}: {count: number}) {
|
|||
borderWidth={1}
|
||||
borderColor={pal.colors.border}
|
||||
color={circleColor}
|
||||
progress={count / MAX_TEXT_LENGTH}
|
||||
progress={count / MAX_LENGTH}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
@ -76,7 +76,11 @@ export function OpenCameraBtn({
|
|||
hitSlop={HITSLOP}>
|
||||
<FontAwesomeIcon
|
||||
icon="camera"
|
||||
style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle}
|
||||
style={
|
||||
(enabled
|
||||
? pal.link
|
||||
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
|
||||
}
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
|
|
@ -86,7 +86,11 @@ export function SelectPhotoBtn({
|
|||
hitSlop={HITSLOP}>
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'image']}
|
||||
style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle}
|
||||
style={
|
||||
(enabled
|
||||
? pal.link
|
||||
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
|
||||
}
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
|
|
@ -9,13 +9,13 @@ import PasteInput, {
|
|||
PastedFile,
|
||||
PasteInputRef,
|
||||
} from '@mattermost/react-native-paste-input'
|
||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
|
||||
import {Autocomplete} from './mobile/Autocomplete'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {useStores} from 'state/index'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
|
||||
import {getImageDim} from 'lib/media/manip'
|
||||
import {cropAndCompressFlow} from 'lib/media/picker'
|
||||
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
|
||||
|
@ -33,11 +33,11 @@ export interface TextInputRef {
|
|||
}
|
||||
|
||||
interface TextInputProps {
|
||||
text: string
|
||||
richtext: RichText
|
||||
placeholder: string
|
||||
suggestedLinks: Set<string>
|
||||
autocompleteView: UserAutocompleteViewModel
|
||||
onTextChanged: (v: string) => void
|
||||
setRichText: (v: RichText) => void
|
||||
onPhotoPasted: (uri: string) => void
|
||||
onSuggestedLinksChanged: (uris: Set<string>) => void
|
||||
onError: (err: string) => void
|
||||
|
@ -51,11 +51,11 @@ interface Selection {
|
|||
export const TextInput = React.forwardRef(
|
||||
(
|
||||
{
|
||||
text,
|
||||
richtext,
|
||||
placeholder,
|
||||
suggestedLinks,
|
||||
autocompleteView,
|
||||
onTextChanged,
|
||||
setRichText,
|
||||
onPhotoPasted,
|
||||
onSuggestedLinksChanged,
|
||||
onError,
|
||||
|
@ -92,7 +92,9 @@ export const TextInput = React.forwardRef(
|
|||
|
||||
const onChangeText = React.useCallback(
|
||||
(newText: string) => {
|
||||
onTextChanged(newText)
|
||||
const newRt = new RichText({text: newText})
|
||||
newRt.detectFacetsWithoutResolution()
|
||||
setRichText(newRt)
|
||||
|
||||
const prefix = getMentionAt(
|
||||
newText,
|
||||
|
@ -105,20 +107,21 @@ export const TextInput = React.forwardRef(
|
|||
autocompleteView.setActive(false)
|
||||
}
|
||||
|
||||
const ents = extractEntities(newText)?.filter(
|
||||
ent => ent.type === 'link',
|
||||
)
|
||||
const set = new Set(ents ? ents.map(e => e.value) : [])
|
||||
const set: Set<string> = new Set()
|
||||
if (newRt.facets) {
|
||||
for (const facet of newRt.facets) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
set.add(feature.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isEqual(set, suggestedLinks)) {
|
||||
onSuggestedLinksChanged(set)
|
||||
}
|
||||
},
|
||||
[
|
||||
onTextChanged,
|
||||
autocompleteView,
|
||||
suggestedLinks,
|
||||
onSuggestedLinksChanged,
|
||||
],
|
||||
[setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged],
|
||||
)
|
||||
|
||||
const onPaste = React.useCallback(
|
||||
|
@ -159,31 +162,35 @@ export const TextInput = React.forwardRef(
|
|||
const onSelectAutocompleteItem = React.useCallback(
|
||||
(item: string) => {
|
||||
onChangeText(
|
||||
insertMentionAt(text, textInputSelection.current?.start || 0, item),
|
||||
insertMentionAt(
|
||||
richtext.text,
|
||||
textInputSelection.current?.start || 0,
|
||||
item,
|
||||
),
|
||||
)
|
||||
autocompleteView.setActive(false)
|
||||
},
|
||||
[onChangeText, text, autocompleteView],
|
||||
[onChangeText, richtext, autocompleteView],
|
||||
)
|
||||
|
||||
const textDecorated = React.useMemo(() => {
|
||||
let i = 0
|
||||
return detectLinkables(text).map(v => {
|
||||
if (typeof v === 'string') {
|
||||
return Array.from(richtext.segments()).map(segment => {
|
||||
if (!segment.facet) {
|
||||
return (
|
||||
<Text key={i++} style={[pal.text, styles.textInputFormatting]}>
|
||||
{v}
|
||||
{segment.text}
|
||||
</Text>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Text key={i++} style={[pal.link, styles.textInputFormatting]}>
|
||||
{v.link}
|
||||
{segment.text}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
})
|
||||
}, [text, pal.link, pal.text])
|
||||
}, [richtext, pal.link, pal.text])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {RichText} from '@atproto/api'
|
||||
import {useEditor, EditorContent, JSONContent} from '@tiptap/react'
|
||||
import {Document} from '@tiptap/extension-document'
|
||||
import {Link} from '@tiptap/extension-link'
|
||||
|
@ -17,11 +18,11 @@ export interface TextInputRef {
|
|||
}
|
||||
|
||||
interface TextInputProps {
|
||||
text: string
|
||||
richtext: RichText
|
||||
placeholder: string
|
||||
suggestedLinks: Set<string>
|
||||
autocompleteView: UserAutocompleteViewModel
|
||||
onTextChanged: (v: string) => void
|
||||
setRichText: (v: RichText) => void
|
||||
onPhotoPasted: (uri: string) => void
|
||||
onSuggestedLinksChanged: (uris: Set<string>) => void
|
||||
onError: (err: string) => void
|
||||
|
@ -30,11 +31,11 @@ interface TextInputProps {
|
|||
export const TextInput = React.forwardRef(
|
||||
(
|
||||
{
|
||||
text,
|
||||
richtext,
|
||||
placeholder,
|
||||
suggestedLinks,
|
||||
autocompleteView,
|
||||
onTextChanged,
|
||||
setRichText,
|
||||
// onPhotoPasted, TODO
|
||||
onSuggestedLinksChanged,
|
||||
}: // onError, TODO
|
||||
|
@ -60,15 +61,15 @@ export const TextInput = React.forwardRef(
|
|||
}),
|
||||
Text,
|
||||
],
|
||||
content: text,
|
||||
content: richtext.text.toString(),
|
||||
autofocus: true,
|
||||
editable: true,
|
||||
injectCSS: true,
|
||||
onUpdate({editor: editorProp}) {
|
||||
const json = editorProp.getJSON()
|
||||
|
||||
const newText = editorJsonToText(json).trim()
|
||||
onTextChanged(newText)
|
||||
const newRt = new RichText({text: editorJsonToText(json).trim()})
|
||||
setRichText(newRt)
|
||||
|
||||
const newSuggestedLinks = new Set(editorJsonToLinks(json))
|
||||
if (!isEqual(newSuggestedLinks, suggestedLinks)) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {AppBskyActorRef, AppBskyActorProfile} from '@atproto/api'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
|
||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||
import {Text} from '../util/text/Text'
|
||||
|
@ -12,9 +12,9 @@ export const SuggestedFollows = ({
|
|||
}: {
|
||||
title: string
|
||||
suggestions: (
|
||||
| AppBskyActorRef.WithInfo
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| AppBskyActorDefs.ProfileView
|
||||
| RefWithInfoAndFollowers
|
||||
| AppBskyActorProfile.View
|
||||
)[]
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
|
@ -28,7 +28,6 @@ export const SuggestedFollows = ({
|
|||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
declarationCid={item.declaration.cid}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
|
@ -36,12 +35,12 @@ export const SuggestedFollows = ({
|
|||
noBorder
|
||||
description={
|
||||
item.description
|
||||
? (item as AppBskyActorProfile.View).description
|
||||
? (item as AppBskyActorDefs.ProfileView).description
|
||||
: ''
|
||||
}
|
||||
followers={
|
||||
item.followers
|
||||
? (item.followers as AppBskyActorProfile.View[])
|
||||
? (item.followers as AppBskyActorDefs.ProfileView[])
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -105,7 +105,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
|
|||
track('EditHandle:SetNewHandle')
|
||||
const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
|
||||
store.log.debug(`Updating handle to ${newHandle}`)
|
||||
await store.api.com.atproto.handle.update({
|
||||
await store.agent.updateHandle({
|
||||
handle: newHandle,
|
||||
})
|
||||
store.shell.closeModal()
|
||||
|
@ -310,7 +310,7 @@ function CustomHandleForm({
|
|||
try {
|
||||
setIsVerifying(true)
|
||||
setError('')
|
||||
const res = await store.api.com.atproto.handle.resolve({handle})
|
||||
const res = await store.agent.com.atproto.identity.resolveHandle({handle})
|
||||
if (res.data.did === store.me.did) {
|
||||
setCanSave(true)
|
||||
} else {
|
||||
|
@ -331,7 +331,7 @@ function CustomHandleForm({
|
|||
canSave,
|
||||
onPressSave,
|
||||
store.log,
|
||||
store.api,
|
||||
store.agent,
|
||||
])
|
||||
|
||||
// rendering
|
||||
|
|
|
@ -39,7 +39,7 @@ export function Component({
|
|||
}
|
||||
}
|
||||
return (
|
||||
<View style={[s.flex1, s.pl10, s.pr10]}>
|
||||
<View testID="confirmModal" style={[s.flex1, s.pl10, s.pr10]}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{typeof message === 'string' ? (
|
||||
<Text style={styles.description}>{message}</Text>
|
||||
|
@ -56,7 +56,7 @@ export function Component({
|
|||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity style={s.mt10} onPress={onPress}>
|
||||
<TouchableOpacity testID="confirmBtn" style={s.mt10} onPress={onPress}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
|
|
|
@ -32,7 +32,7 @@ export function Component({}: {}) {
|
|||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await store.api.com.atproto.account.requestDelete()
|
||||
await store.agent.com.atproto.server.requestAccountDelete()
|
||||
setIsEmailSent(true)
|
||||
} catch (e: any) {
|
||||
setError(cleanError(e))
|
||||
|
@ -43,7 +43,7 @@ export function Component({}: {}) {
|
|||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await store.api.com.atproto.account.delete({
|
||||
await store.agent.com.atproto.server.deleteAccount({
|
||||
did: store.me.did,
|
||||
password,
|
||||
token: confirmCode,
|
||||
|
|
|
@ -123,7 +123,7 @@ export function Component({
|
|||
}
|
||||
|
||||
return (
|
||||
<View style={[s.flex1, pal.view]}>
|
||||
<View style={[s.flex1, pal.view]} testID="editProfileModal">
|
||||
<ScrollView style={styles.inner}>
|
||||
<Text style={[styles.title, pal.text]}>Edit my profile</Text>
|
||||
<View style={styles.photos}>
|
||||
|
@ -147,6 +147,7 @@ export function Component({
|
|||
<View>
|
||||
<Text style={[styles.label, pal.text]}>Display Name</Text>
|
||||
<TextInput
|
||||
testID="editProfileDisplayNameInput"
|
||||
style={[styles.textInput, pal.text]}
|
||||
placeholder="e.g. Alice Roberts"
|
||||
placeholderTextColor={colors.gray4}
|
||||
|
@ -157,6 +158,7 @@ export function Component({
|
|||
<View style={s.pb10}>
|
||||
<Text style={[styles.label, pal.text]}>Description</Text>
|
||||
<TextInput
|
||||
testID="editProfileDescriptionInput"
|
||||
style={[styles.textArea, pal.text]}
|
||||
placeholder="e.g. Artist, dog-lover, and memelord."
|
||||
placeholderTextColor={colors.gray4}
|
||||
|
@ -171,7 +173,10 @@ export function Component({
|
|||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity style={s.mt10} onPress={onPressSave}>
|
||||
<TouchableOpacity
|
||||
testID="editProfileSaveBtn"
|
||||
style={s.mt10}
|
||||
onPress={onPressSave}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
|
@ -181,7 +186,10 @@ export function Component({
|
|||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={s.mt5} onPress={onPressCancel}>
|
||||
<TouchableOpacity
|
||||
testID="editProfileCancelBtn"
|
||||
style={s.mt5}
|
||||
onPress={onPressCancel}>
|
||||
<View style={[styles.btn]}>
|
||||
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
|
||||
</View>
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ComAtprotoReportReasonType} from '@atproto/api'
|
||||
import {ComAtprotoModerationDefs} from '@atproto/api'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {useStores} from 'state/index'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
|
@ -39,16 +39,16 @@ export function Component({did}: {did: string}) {
|
|||
setIsProcessing(true)
|
||||
try {
|
||||
// NOTE: we should update the lexicon of reasontype to include more options -prf
|
||||
let reasonType = ComAtprotoReportReasonType.OTHER
|
||||
let reasonType = ComAtprotoModerationDefs.REASONOTHER
|
||||
if (issue === 'spam') {
|
||||
reasonType = ComAtprotoReportReasonType.SPAM
|
||||
reasonType = ComAtprotoModerationDefs.REASONSPAM
|
||||
}
|
||||
const reason = ITEMS.find(item => item.key === issue)?.label || ''
|
||||
await store.api.com.atproto.report.create({
|
||||
await store.agent.com.atproto.moderation.createReport({
|
||||
reasonType,
|
||||
reason,
|
||||
subject: {
|
||||
$type: 'com.atproto.repo.repoRef',
|
||||
$type: 'com.atproto.admin.defs#repoRef',
|
||||
did,
|
||||
},
|
||||
})
|
||||
|
@ -61,12 +61,18 @@ export function Component({did}: {did: string}) {
|
|||
}
|
||||
}
|
||||
return (
|
||||
<View style={[s.flex1, s.pl10, s.pr10, pal.view]}>
|
||||
<View
|
||||
testID="reportAccountModal"
|
||||
style={[s.flex1, s.pl10, s.pr10, pal.view]}>
|
||||
<Text style={[pal.text, styles.title]}>Report account</Text>
|
||||
<Text style={[pal.textLight, styles.description]}>
|
||||
What is the issue with this account?
|
||||
</Text>
|
||||
<RadioGroup items={ITEMS} onSelect={onSelectIssue} />
|
||||
<RadioGroup
|
||||
testID="reportAccountRadios"
|
||||
items={ITEMS}
|
||||
onSelect={onSelectIssue}
|
||||
/>
|
||||
{error ? (
|
||||
<View style={s.mt10}>
|
||||
<ErrorMessage message={error} />
|
||||
|
@ -77,7 +83,10 @@ export function Component({did}: {did: string}) {
|
|||
<ActivityIndicator />
|
||||
</View>
|
||||
) : issue ? (
|
||||
<TouchableOpacity style={s.mt10} onPress={onPress}>
|
||||
<TouchableOpacity
|
||||
testID="sendReportBtn"
|
||||
style={s.mt10}
|
||||
onPress={onPress}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ComAtprotoReportReasonType} from '@atproto/api'
|
||||
import {ComAtprotoModerationDefs} from '@atproto/api'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {useStores} from 'state/index'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
|
@ -46,16 +46,16 @@ export function Component({
|
|||
setIsProcessing(true)
|
||||
try {
|
||||
// NOTE: we should update the lexicon of reasontype to include more options -prf
|
||||
let reasonType = ComAtprotoReportReasonType.OTHER
|
||||
let reasonType = ComAtprotoModerationDefs.REASONOTHER
|
||||
if (issue === 'spam') {
|
||||
reasonType = ComAtprotoReportReasonType.SPAM
|
||||
reasonType = ComAtprotoModerationDefs.REASONSPAM
|
||||
}
|
||||
const reason = ITEMS.find(item => item.key === issue)?.label || ''
|
||||
await store.api.com.atproto.report.create({
|
||||
await store.agent.createModerationReport({
|
||||
reasonType,
|
||||
reason,
|
||||
subject: {
|
||||
$type: 'com.atproto.repo.recordRef',
|
||||
$type: 'com.atproto.repo.strongRef',
|
||||
uri: postUri,
|
||||
cid: postCid,
|
||||
},
|
||||
|
@ -69,12 +69,16 @@ export function Component({
|
|||
}
|
||||
}
|
||||
return (
|
||||
<View style={[s.flex1, s.pl10, s.pr10, pal.view]}>
|
||||
<View testID="reportPostModal" style={[s.flex1, s.pl10, s.pr10, pal.view]}>
|
||||
<Text style={[pal.text, styles.title]}>Report post</Text>
|
||||
<Text style={[pal.textLight, styles.description]}>
|
||||
What is the issue with this post?
|
||||
</Text>
|
||||
<RadioGroup items={ITEMS} onSelect={onSelectIssue} />
|
||||
<RadioGroup
|
||||
testID="reportPostRadios"
|
||||
items={ITEMS}
|
||||
onSelect={onSelectIssue}
|
||||
/>
|
||||
{error ? (
|
||||
<View style={s.mt10}>
|
||||
<ErrorMessage message={error} />
|
||||
|
@ -85,7 +89,10 @@ export function Component({
|
|||
<ActivityIndicator />
|
||||
</View>
|
||||
) : issue ? (
|
||||
<TouchableOpacity style={s.mt10} onPress={onPress}>
|
||||
<TouchableOpacity
|
||||
testID="sendReportBtn"
|
||||
style={s.mt10}
|
||||
onPress={onPress}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
|
|
|
@ -26,22 +26,28 @@ export function Component({
|
|||
}
|
||||
|
||||
return (
|
||||
<View style={[s.flex1, pal.view, styles.container]}>
|
||||
<View testID="repostModal" style={[s.flex1, pal.view, styles.container]}>
|
||||
<View style={s.pb20}>
|
||||
<TouchableOpacity style={[styles.actionBtn]} onPress={onRepost}>
|
||||
<TouchableOpacity
|
||||
testID="repostBtn"
|
||||
style={[styles.actionBtn]}
|
||||
onPress={onRepost}>
|
||||
<RepostIcon strokeWidth={2} size={24} style={s.blue3} />
|
||||
<Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
|
||||
{!isReposted ? 'Repost' : 'Undo repost'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.actionBtn]} onPress={onQuote}>
|
||||
<TouchableOpacity
|
||||
testID="quoteBtn"
|
||||
style={[styles.actionBtn]}
|
||||
onPress={onQuote}>
|
||||
<FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} />
|
||||
<Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
|
||||
Quote Post
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<TouchableOpacity testID="cancelBtn" onPress={onPress}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
|
|
|
@ -47,10 +47,10 @@ export const FeedItem = observer(function FeedItem({
|
|||
const pal = usePalette('default')
|
||||
const [isAuthorsExpanded, setAuthorsExpanded] = React.useState<boolean>(false)
|
||||
const itemHref = React.useMemo(() => {
|
||||
if (item.isUpvote || item.isRepost) {
|
||||
if (item.isLike || item.isRepost) {
|
||||
const urip = new AtUri(item.subjectUri)
|
||||
return `/profile/${urip.host}/post/${urip.rkey}`
|
||||
} else if (item.isFollow || item.isAssertion) {
|
||||
} else if (item.isFollow) {
|
||||
return `/profile/${item.author.handle}`
|
||||
} else if (item.isReply) {
|
||||
const urip = new AtUri(item.uri)
|
||||
|
@ -59,9 +59,9 @@ export const FeedItem = observer(function FeedItem({
|
|||
return ''
|
||||
}, [item])
|
||||
const itemTitle = React.useMemo(() => {
|
||||
if (item.isUpvote || item.isRepost) {
|
||||
if (item.isLike || item.isRepost) {
|
||||
return 'Post'
|
||||
} else if (item.isFollow || item.isAssertion) {
|
||||
} else if (item.isFollow) {
|
||||
return item.author.handle
|
||||
} else if (item.isReply) {
|
||||
return 'Post'
|
||||
|
@ -77,7 +77,7 @@ export const FeedItem = observer(function FeedItem({
|
|||
return <View />
|
||||
}
|
||||
|
||||
if (item.isReply || item.isMention) {
|
||||
if (item.isReply || item.isMention || item.isQuote) {
|
||||
if (item.additionalPost?.error) {
|
||||
// hide errors - it doesnt help the user to show them
|
||||
return <View />
|
||||
|
@ -103,7 +103,7 @@ export const FeedItem = observer(function FeedItem({
|
|||
let action = ''
|
||||
let icon: Props['icon'] | 'HeartIconSolid'
|
||||
let iconStyle: Props['style'] = []
|
||||
if (item.isUpvote) {
|
||||
if (item.isLike) {
|
||||
action = 'liked your post'
|
||||
icon = 'HeartIconSolid'
|
||||
iconStyle = [
|
||||
|
@ -114,9 +114,6 @@ export const FeedItem = observer(function FeedItem({
|
|||
action = 'reposted your post'
|
||||
icon = 'retweet'
|
||||
iconStyle = [s.green3 as FontAwesomeIconStyle]
|
||||
} else if (item.isReply) {
|
||||
action = 'replied to your post'
|
||||
icon = ['far', 'comment']
|
||||
} else if (item.isFollow) {
|
||||
action = 'followed you'
|
||||
icon = 'user-plus'
|
||||
|
@ -208,7 +205,7 @@ export const FeedItem = observer(function FeedItem({
|
|||
</View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
{item.isUpvote || item.isRepost ? (
|
||||
{item.isLike || item.isRepost || item.isQuote ? (
|
||||
<AdditionalPostText additionalPost={item.additionalPost} />
|
||||
) : (
|
||||
<></>
|
||||
|
@ -352,9 +349,9 @@ function AdditionalPostText({
|
|||
return <View />
|
||||
}
|
||||
const text = additionalPost.thread?.postRecord.text
|
||||
const images = (
|
||||
additionalPost.thread.post.embed as AppBskyEmbedImages.Presented
|
||||
)?.images
|
||||
const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed)
|
||||
? additionalPost.thread.post.embed.images
|
||||
: undefined
|
||||
return (
|
||||
<>
|
||||
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
||||
|
|
|
@ -9,7 +9,9 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
|
||||
export const FeedsTabBar = observer(
|
||||
(props: RenderTabBarFnProps & {onPressSelected: () => void}) => {
|
||||
(
|
||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||
) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const interp = useAnimatedValue(0)
|
||||
|
@ -32,7 +34,10 @@ export const FeedsTabBar = observer(
|
|||
|
||||
return (
|
||||
<Animated.View style={[pal.view, styles.tabBar, transform]}>
|
||||
<TouchableOpacity style={styles.tabBarAvi} onPress={onPressAvi}>
|
||||
<TouchableOpacity
|
||||
testID="viewHeaderDrawerBtn"
|
||||
style={styles.tabBarAvi}
|
||||
onPress={onPressAvi}>
|
||||
<UserAvatar avatar={store.me.avatar} size={30} />
|
||||
</TouchableOpacity>
|
||||
<TabBar
|
||||
|
|
|
@ -20,6 +20,7 @@ interface Props {
|
|||
initialPage?: number
|
||||
renderTabBar: RenderTabBarFn
|
||||
onPageSelected?: (index: number) => void
|
||||
testID?: string
|
||||
}
|
||||
export const Pager = ({
|
||||
children,
|
||||
|
@ -27,6 +28,7 @@ export const Pager = ({
|
|||
initialPage = 0,
|
||||
renderTabBar,
|
||||
onPageSelected,
|
||||
testID,
|
||||
}: React.PropsWithChildren<Props>) => {
|
||||
const [selectedPage, setSelectedPage] = React.useState(0)
|
||||
const position = useAnimatedValue(0)
|
||||
|
@ -49,7 +51,7 @@ export const Pager = ({
|
|||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View testID={testID}>
|
||||
{tabBarPosition === 'top' &&
|
||||
renderTabBar({
|
||||
selectedPage,
|
||||
|
|
|
@ -15,6 +15,7 @@ interface Layout {
|
|||
}
|
||||
|
||||
export interface TabBarProps {
|
||||
testID?: string
|
||||
selectedPage: number
|
||||
items: string[]
|
||||
position: Animated.Value
|
||||
|
@ -26,6 +27,7 @@ export interface TabBarProps {
|
|||
}
|
||||
|
||||
export function TabBar({
|
||||
testID,
|
||||
selectedPage,
|
||||
items,
|
||||
position,
|
||||
|
@ -92,12 +94,15 @@ export function TabBar({
|
|||
}
|
||||
|
||||
return (
|
||||
<View style={[pal.view, styles.outer]} onLayout={onLayout}>
|
||||
<View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}>
|
||||
<Animated.View style={[styles.indicator, indicatorStyle]} />
|
||||
{items.map((item, i) => {
|
||||
const selected = i === selectedPage
|
||||
return (
|
||||
<TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}>
|
||||
<TouchableWithoutFeedback
|
||||
key={i}
|
||||
testID={testID ? `${testID}-${item}` : undefined}
|
||||
onPress={() => onPressItem(i)}>
|
||||
<View
|
||||
style={
|
||||
indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom
|
||||
|
|
|
@ -2,24 +2,18 @@ import React, {useEffect} from 'react'
|
|||
import {observer} from 'mobx-react-lite'
|
||||
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
|
||||
import {CenteredView, FlatList} from '../util/Views'
|
||||
import {VotesViewModel, VoteItem} from 'state/models/votes-view'
|
||||
import {LikesViewModel, LikeItem} from 'state/models/likes-view'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export const PostVotedBy = observer(function PostVotedBy({
|
||||
uri,
|
||||
direction,
|
||||
}: {
|
||||
uri: string
|
||||
direction: 'up' | 'down'
|
||||
}) {
|
||||
export const PostLikedBy = observer(function PostVotedBy({uri}: {uri: string}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const view = React.useMemo(
|
||||
() => new VotesViewModel(store, {uri, direction}),
|
||||
[store, uri, direction],
|
||||
() => new LikesViewModel(store, {uri}),
|
||||
[store, uri],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -55,11 +49,10 @@ export const PostVotedBy = observer(function PostVotedBy({
|
|||
|
||||
// loaded
|
||||
// =
|
||||
const renderItem = ({item}: {item: VoteItem}) => (
|
||||
const renderItem = ({item}: {item: LikeItem}) => (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.actor.did}
|
||||
did={item.actor.did}
|
||||
declarationCid={item.actor.declaration.cid}
|
||||
handle={item.actor.handle}
|
||||
displayName={item.actor.displayName}
|
||||
avatar={item.actor.avatar}
|
||||
|
@ -68,7 +61,7 @@ export const PostVotedBy = observer(function PostVotedBy({
|
|||
)
|
||||
return (
|
||||
<FlatList
|
||||
data={view.votes}
|
||||
data={view.likes}
|
||||
keyExtractor={item => item.actor.did}
|
||||
refreshControl={
|
||||
<RefreshControl
|
|
@ -64,7 +64,6 @@ export const PostRepostedBy = observer(function PostRepostedBy({
|
|||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
declarationCid={item.declaration.cid}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
|
|
|
@ -1,17 +1,30 @@
|
|||
import React, {useRef} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {CenteredView, FlatList} from '../util/Views'
|
||||
import {
|
||||
PostThreadViewModel,
|
||||
PostThreadViewPostModel,
|
||||
} from 'state/models/post-thread-view'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {PostThreadItem} from './PostThreadItem'
|
||||
import {ComposePrompt} from '../composer/Prompt'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {s} from 'lib/styles'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
|
||||
const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
|
||||
const BOTTOM_BORDER = {
|
||||
|
@ -32,6 +45,7 @@ export const PostThread = observer(function PostThread({
|
|||
const pal = usePalette('default')
|
||||
const ref = useRef<FlatList>(null)
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const posts = React.useMemo(() => {
|
||||
if (view.thread) {
|
||||
return Array.from(flattenThread(view.thread)).concat([BOTTOM_BORDER])
|
||||
|
@ -41,6 +55,7 @@ export const PostThread = observer(function PostThread({
|
|||
|
||||
// events
|
||||
// =
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
|
@ -50,6 +65,7 @@ export const PostThread = observer(function PostThread({
|
|||
}
|
||||
setIsRefreshing(false)
|
||||
}, [view, setIsRefreshing])
|
||||
|
||||
const onLayout = React.useCallback(() => {
|
||||
const index = posts.findIndex(post => post._isHighlightedPost)
|
||||
if (index !== -1) {
|
||||
|
@ -60,6 +76,7 @@ export const PostThread = observer(function PostThread({
|
|||
})
|
||||
}
|
||||
}, [posts, ref])
|
||||
|
||||
const onScrollToIndexFailed = React.useCallback(
|
||||
(info: {
|
||||
index: number
|
||||
|
@ -73,6 +90,15 @@ export const PostThread = observer(function PostThread({
|
|||
},
|
||||
[ref],
|
||||
)
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Home')
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({item}: {item: YieldedItem}) => {
|
||||
if (item === REPLY_PROMPT) {
|
||||
|
@ -104,6 +130,30 @@ export const PostThread = observer(function PostThread({
|
|||
// error
|
||||
// =
|
||||
if (view.hasError) {
|
||||
if (view.notFound) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
|
||||
<Text type="title-lg" style={[pal.text, s.mb5]}>
|
||||
Post not found
|
||||
</Text>
|
||||
<Text type="md" style={[pal.text, s.mb10]}>
|
||||
The post may have been deleted.
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onPressBack}>
|
||||
<Text type="2xl" style={pal.link}>
|
||||
<FontAwesomeIcon
|
||||
icon="angle-left"
|
||||
style={[pal.link as FontAwesomeIconStyle, s.mr5]}
|
||||
size={14}
|
||||
/>
|
||||
Back
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<CenteredView>
|
||||
<ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
|
||||
|
@ -159,12 +209,18 @@ function* flattenThread(
|
|||
yield* flattenThread(reply as PostThreadViewPostModel)
|
||||
}
|
||||
}
|
||||
} else if (!isAscending && !post.parent && post.post.replyCount > 0) {
|
||||
} else if (!isAscending && !post.parent && post.post.replyCount) {
|
||||
post._hasMore = true
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
notFoundContainer: {
|
||||
margin: 10,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 6,
|
||||
},
|
||||
bottomBorder: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
|
|
|
@ -19,7 +19,7 @@ import {ago} from 'lib/strings/time'
|
|||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {useStores} from 'state/index'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostEmbeds} from '../util/PostEmbeds'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostCtrls} from '../util/PostCtrls'
|
||||
import {PostMutedWrapper} from '../util/PostMuted'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
|
@ -38,7 +38,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
const store = useStores()
|
||||
const [deleted, setDeleted] = React.useState(false)
|
||||
const record = item.postRecord
|
||||
const hasEngagement = item.post.upvoteCount || item.post.repostCount
|
||||
const hasEngagement = item.post.likeCount || item.post.repostCount
|
||||
|
||||
const itemUri = item.post.uri
|
||||
const itemCid = item.post.cid
|
||||
|
@ -49,11 +49,11 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
const itemTitle = `Post by ${item.post.author.handle}`
|
||||
const authorHref = `/profile/${item.post.author.handle}`
|
||||
const authorTitle = item.post.author.handle
|
||||
const upvotesHref = React.useMemo(() => {
|
||||
const likesHref = React.useMemo(() => {
|
||||
const urip = new AtUri(item.post.uri)
|
||||
return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by`
|
||||
return `/profile/${item.post.author.handle}/post/${urip.rkey}/liked-by`
|
||||
}, [item.post.uri, item.post.author.handle])
|
||||
const upvotesTitle = 'Likes on this post'
|
||||
const likesTitle = 'Likes on this post'
|
||||
const repostsHref = React.useMemo(() => {
|
||||
const urip = new AtUri(item.post.uri)
|
||||
return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by`
|
||||
|
@ -80,10 +80,10 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
.toggleRepost()
|
||||
.catch(e => store.log.error('Failed to toggle repost', e))
|
||||
}, [item, store])
|
||||
const onPressToggleUpvote = React.useCallback(() => {
|
||||
const onPressToggleLike = React.useCallback(() => {
|
||||
return item
|
||||
.toggleUpvote()
|
||||
.catch(e => store.log.error('Failed to toggle upvote', e))
|
||||
.toggleLike()
|
||||
.catch(e => store.log.error('Failed to toggle like', e))
|
||||
}, [item, store])
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
Clipboard.setString(record?.text || '')
|
||||
|
@ -125,8 +125,8 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
|
||||
if (item._isHighlightedPost) {
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
testID={`postThreadItem-by-${item.post.author.handle}`}
|
||||
style={[
|
||||
styles.outer,
|
||||
styles.outerHighlighted,
|
||||
|
@ -160,6 +160,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
</View>
|
||||
<View style={s.flex1} />
|
||||
<PostDropdownBtn
|
||||
testID="postDropdownBtn"
|
||||
style={styles.metaItem}
|
||||
itemUri={itemUri}
|
||||
itemCid={itemCid}
|
||||
|
@ -191,10 +192,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
<View style={[s.pl10, s.pr10, s.pb10]}>
|
||||
{item.richText?.text ? (
|
||||
<View
|
||||
style={[
|
||||
styles.postTextContainer,
|
||||
styles.postTextLargeContainer,
|
||||
]}>
|
||||
style={[styles.postTextContainer, styles.postTextLargeContainer]}>
|
||||
<RichText
|
||||
type="post-text-lg"
|
||||
richText={item.richText}
|
||||
|
@ -210,7 +208,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
style={styles.expandedInfoItem}
|
||||
href={repostsHref}
|
||||
title={repostsTitle}>
|
||||
<Text type="lg" style={pal.textLight}>
|
||||
<Text testID="repostCount" type="lg" style={pal.textLight}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
{item.post.repostCount}
|
||||
</Text>{' '}
|
||||
|
@ -220,16 +218,16 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
) : (
|
||||
<></>
|
||||
)}
|
||||
{item.post.upvoteCount ? (
|
||||
{item.post.likeCount ? (
|
||||
<Link
|
||||
style={styles.expandedInfoItem}
|
||||
href={upvotesHref}
|
||||
title={upvotesTitle}>
|
||||
<Text type="lg" style={pal.textLight}>
|
||||
href={likesHref}
|
||||
title={likesTitle}>
|
||||
<Text testID="likeCount" type="lg" style={pal.textLight}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
{item.post.upvoteCount}
|
||||
{item.post.likeCount}
|
||||
</Text>{' '}
|
||||
{pluralize(item.post.upvoteCount, 'like')}
|
||||
{pluralize(item.post.likeCount, 'like')}
|
||||
</Text>
|
||||
</Link>
|
||||
) : (
|
||||
|
@ -254,11 +252,11 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
text={item.richText?.text || record.text}
|
||||
indexedAt={item.post.indexedAt}
|
||||
isAuthor={item.post.author.did === store.me.did}
|
||||
isReposted={!!item.post.viewer.repost}
|
||||
isUpvoted={!!item.post.viewer.upvote}
|
||||
isReposted={!!item.post.viewer?.repost}
|
||||
isLiked={!!item.post.viewer?.like}
|
||||
onPressReply={onPressReply}
|
||||
onPressToggleRepost={onPressToggleRepost}
|
||||
onPressToggleUpvote={onPressToggleUpvote}
|
||||
onPressToggleLike={onPressToggleLike}
|
||||
onCopyPostText={onCopyPostText}
|
||||
onOpenTranslate={onOpenTranslate}
|
||||
onDeletePost={onDeletePost}
|
||||
|
@ -266,12 +264,12 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}>
|
||||
<Link
|
||||
testID={`postThreadItem-by-${item.post.author.handle}`}
|
||||
style={[styles.outer, {borderTopColor: pal.colors.border}, pal.view]}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
|
@ -305,7 +303,6 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
timestamp={item.post.indexedAt}
|
||||
postHref={itemHref}
|
||||
did={item.post.author.did}
|
||||
declarationCid={item.post.author.declaration.cid}
|
||||
/>
|
||||
{item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
|
@ -333,12 +330,12 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
isAuthor={item.post.author.did === store.me.did}
|
||||
replyCount={item.post.replyCount}
|
||||
repostCount={item.post.repostCount}
|
||||
upvoteCount={item.post.upvoteCount}
|
||||
isReposted={!!item.post.viewer.repost}
|
||||
isUpvoted={!!item.post.viewer.upvote}
|
||||
likeCount={item.post.likeCount}
|
||||
isReposted={!!item.post.viewer?.repost}
|
||||
isLiked={!!item.post.viewer?.like}
|
||||
onPressReply={onPressReply}
|
||||
onPressToggleRepost={onPressToggleRepost}
|
||||
onPressToggleUpvote={onPressToggleUpvote}
|
||||
onPressToggleLike={onPressToggleLike}
|
||||
onCopyPostText={onCopyPostText}
|
||||
onOpenTranslate={onOpenTranslate}
|
||||
onDeletePost={onDeletePost}
|
||||
|
|
|
@ -15,7 +15,7 @@ import {PostThreadViewModel} from 'state/models/post-thread-view'
|
|||
import {Link} from '../util/Link'
|
||||
import {UserInfoText} from '../util/UserInfoText'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostEmbeds} from '../util/PostEmbeds'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostCtrls} from '../util/PostCtrls'
|
||||
import {PostMutedWrapper} from '../util/PostMuted'
|
||||
import {Text} from '../util/text/Text'
|
||||
|
@ -118,10 +118,10 @@ export const Post = observer(function Post({
|
|||
.toggleRepost()
|
||||
.catch(e => store.log.error('Failed to toggle repost', e))
|
||||
}
|
||||
const onPressToggleUpvote = () => {
|
||||
const onPressToggleLike = () => {
|
||||
return item
|
||||
.toggleUpvote()
|
||||
.catch(e => store.log.error('Failed to toggle upvote', e))
|
||||
.toggleLike()
|
||||
.catch(e => store.log.error('Failed to toggle like', e))
|
||||
}
|
||||
const onCopyPostText = () => {
|
||||
Clipboard.setString(record.text)
|
||||
|
@ -166,7 +166,6 @@ export const Post = observer(function Post({
|
|||
timestamp={item.post.indexedAt}
|
||||
postHref={itemHref}
|
||||
did={item.post.author.did}
|
||||
declarationCid={item.post.author.declaration.cid}
|
||||
/>
|
||||
{replyAuthorDid !== '' && (
|
||||
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
||||
|
@ -211,12 +210,12 @@ export const Post = observer(function Post({
|
|||
isAuthor={item.post.author.did === store.me.did}
|
||||
replyCount={item.post.replyCount}
|
||||
repostCount={item.post.repostCount}
|
||||
upvoteCount={item.post.upvoteCount}
|
||||
isReposted={!!item.post.viewer.repost}
|
||||
isUpvoted={!!item.post.viewer.upvote}
|
||||
likeCount={item.post.likeCount}
|
||||
isReposted={!!item.post.viewer?.repost}
|
||||
isLiked={!!item.post.viewer?.like}
|
||||
onPressReply={onPressReply}
|
||||
onPressToggleRepost={onPressToggleRepost}
|
||||
onPressToggleUpvote={onPressToggleUpvote}
|
||||
onPressToggleLike={onPressToggleLike}
|
||||
onCopyPostText={onCopyPostText}
|
||||
onOpenTranslate={onOpenTranslate}
|
||||
onDeletePost={onDeletePost}
|
||||
|
|
|
@ -128,6 +128,7 @@ export const Feed = observer(function Feed({
|
|||
<View testID={testID} style={style}>
|
||||
{data.length > 0 && (
|
||||
<FlatList
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
keyExtractor={item => item._reactKey}
|
||||
|
|
|
@ -13,7 +13,7 @@ import {Text} from '../util/text/Text'
|
|||
import {UserInfoText} from '../util/UserInfoText'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostCtrls} from '../util/PostCtrls'
|
||||
import {PostEmbeds} from '../util/PostEmbeds'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostMutedWrapper} from '../util/PostMuted'
|
||||
import {RichText} from '../util/text/RichText'
|
||||
import * as Toast from '../util/Toast'
|
||||
|
@ -79,11 +79,11 @@ export const FeedItem = observer(function ({
|
|||
.toggleRepost()
|
||||
.catch(e => store.log.error('Failed to toggle repost', e))
|
||||
}
|
||||
const onPressToggleUpvote = () => {
|
||||
const onPressToggleLike = () => {
|
||||
track('FeedItem:PostLike')
|
||||
return item
|
||||
.toggleUpvote()
|
||||
.catch(e => store.log.error('Failed to toggle upvote', e))
|
||||
.toggleLike()
|
||||
.catch(e => store.log.error('Failed to toggle like', e))
|
||||
}
|
||||
const onCopyPostText = () => {
|
||||
Clipboard.setString(record?.text || '')
|
||||
|
@ -127,7 +127,12 @@ export const FeedItem = observer(function ({
|
|||
|
||||
return (
|
||||
<PostMutedWrapper isMuted={isMuted}>
|
||||
<Link style={outerStyles} href={itemHref} title={itemTitle} noFeedback>
|
||||
<Link
|
||||
testID={`feedItem-by-${item.post.author.handle}`}
|
||||
style={outerStyles}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
noFeedback>
|
||||
{isThreadChild && (
|
||||
<View
|
||||
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
|
||||
|
@ -189,7 +194,6 @@ export const FeedItem = observer(function ({
|
|||
timestamp={item.post.indexedAt}
|
||||
postHref={itemHref}
|
||||
did={item.post.author.did}
|
||||
declarationCid={item.post.author.declaration.cid}
|
||||
showFollowBtn={showFollowBtn}
|
||||
/>
|
||||
{!isThreadChild && replyAuthorDid !== '' && (
|
||||
|
@ -239,12 +243,12 @@ export const FeedItem = observer(function ({
|
|||
isAuthor={item.post.author.did === store.me.did}
|
||||
replyCount={item.post.replyCount}
|
||||
repostCount={item.post.repostCount}
|
||||
upvoteCount={item.post.upvoteCount}
|
||||
isReposted={!!item.post.viewer.repost}
|
||||
isUpvoted={!!item.post.viewer.upvote}
|
||||
likeCount={item.post.likeCount}
|
||||
isReposted={!!item.post.viewer?.repost}
|
||||
isLiked={!!item.post.viewer?.like}
|
||||
onPressReply={onPressReply}
|
||||
onPressToggleRepost={onPressToggleRepost}
|
||||
onPressToggleUpvote={onPressToggleUpvote}
|
||||
onPressToggleLike={onPressToggleLike}
|
||||
onCopyPostText={onCopyPostText}
|
||||
onOpenTranslate={onOpenTranslate}
|
||||
onDeletePost={onDeletePost}
|
||||
|
|
|
@ -2,19 +2,16 @@ import React from 'react'
|
|||
import {observer} from 'mobx-react-lite'
|
||||
import {Button, ButtonType} from '../util/forms/Button'
|
||||
import {useStores} from 'state/index'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import * as Toast from '../util/Toast'
|
||||
|
||||
const FollowButton = observer(
|
||||
({
|
||||
type = 'inverted',
|
||||
did,
|
||||
declarationCid,
|
||||
onToggleFollow,
|
||||
}: {
|
||||
type?: ButtonType
|
||||
did: string
|
||||
declarationCid: string
|
||||
onToggleFollow?: (v: boolean) => void
|
||||
}) => {
|
||||
const store = useStores()
|
||||
|
@ -23,7 +20,7 @@ const FollowButton = observer(
|
|||
const onToggleFollowInner = async () => {
|
||||
if (store.me.follows.isFollowing(did)) {
|
||||
try {
|
||||
await apilib.unfollow(store, store.me.follows.getFollowUri(did))
|
||||
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
|
||||
store.me.follows.removeFollow(did)
|
||||
onToggleFollow?.(false)
|
||||
} catch (e: any) {
|
||||
|
@ -32,7 +29,7 @@ const FollowButton = observer(
|
|||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await apilib.follow(store, did, declarationCid)
|
||||
const res = await store.agent.follow(did)
|
||||
store.me.follows.addFollow(did, res.uri)
|
||||
onToggleFollow?.(true)
|
||||
} catch (e: any) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {AppBskyActorProfile} from '@atproto/api'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
|
@ -11,6 +11,7 @@ import {useStores} from 'state/index'
|
|||
import FollowButton from './FollowButton'
|
||||
|
||||
export function ProfileCard({
|
||||
testID,
|
||||
handle,
|
||||
displayName,
|
||||
avatar,
|
||||
|
@ -21,6 +22,7 @@ export function ProfileCard({
|
|||
followers,
|
||||
renderButton,
|
||||
}: {
|
||||
testID?: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
|
@ -28,12 +30,13 @@ export function ProfileCard({
|
|||
isFollowedBy?: boolean
|
||||
noBg?: boolean
|
||||
noBorder?: boolean
|
||||
followers?: AppBskyActorProfile.View[] | undefined
|
||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||
renderButton?: () => JSX.Element
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<Link
|
||||
testID={testID}
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.border,
|
||||
|
@ -106,7 +109,6 @@ export function ProfileCard({
|
|||
export const ProfileCardWithFollowBtn = observer(
|
||||
({
|
||||
did,
|
||||
declarationCid,
|
||||
handle,
|
||||
displayName,
|
||||
avatar,
|
||||
|
@ -117,7 +119,6 @@ export const ProfileCardWithFollowBtn = observer(
|
|||
followers,
|
||||
}: {
|
||||
did: string
|
||||
declarationCid: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
|
@ -125,7 +126,7 @@ export const ProfileCardWithFollowBtn = observer(
|
|||
isFollowedBy?: boolean
|
||||
noBg?: boolean
|
||||
noBorder?: boolean
|
||||
followers?: AppBskyActorProfile.View[] | undefined
|
||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const isMe = store.me.handle === handle
|
||||
|
@ -140,11 +141,7 @@ export const ProfileCardWithFollowBtn = observer(
|
|||
noBg={noBg}
|
||||
noBorder={noBorder}
|
||||
followers={followers}
|
||||
renderButton={
|
||||
isMe
|
||||
? undefined
|
||||
: () => <FollowButton did={did} declarationCid={declarationCid} />
|
||||
}
|
||||
renderButton={isMe ? undefined : () => <FollowButton did={did} />}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
|
|
@ -19,7 +19,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
|
|||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const view = React.useMemo(
|
||||
() => new UserFollowersViewModel(store, {user: name}),
|
||||
() => new UserFollowersViewModel(store, {actor: name}),
|
||||
[store, name],
|
||||
)
|
||||
|
||||
|
@ -64,7 +64,6 @@ export const ProfileFollowers = observer(function ProfileFollowers({
|
|||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
declarationCid={item.declaration.cid}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
|
|
|
@ -16,7 +16,7 @@ export const ProfileFollows = observer(function ProfileFollows({
|
|||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const view = React.useMemo(
|
||||
() => new UserFollowsViewModel(store, {user: name}),
|
||||
() => new UserFollowsViewModel(store, {actor: name}),
|
||||
[store, name],
|
||||
)
|
||||
|
||||
|
@ -61,7 +61,6 @@ export const ProfileFollows = observer(function ProfileFollows({
|
|||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
declarationCid={item.declaration.cid}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue