Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86426b528a | |||
| 7d01ff90d3 | |||
| cefa0a55e8 | |||
| c3821fdc31 | |||
| 6218eb0eea | |||
| 1f61109cfa | |||
| f91aa37c6b | |||
| 1f587ea4b6 | |||
| aa1d4d4e26 | |||
| f6dc216110 | |||
| 5aed7db208 | |||
| a306fbfca3 | |||
| 7b02b5d716 | |||
| 4fab3c42f9 | |||
| ec5c4929c1 | |||
| 44039c68d6 | |||
| 835f2e6548 | |||
| 14208eef11 | |||
| eb2fd53340 | |||
| 7047755c50 | |||
| f3951f2718 | |||
| ed2c8b720e | |||
| ad97d4350c | |||
| 24bd3d6986 | |||
| bedb0c3fbd | |||
| e3e8f10538 | |||
| 491116ca66 | |||
| 4e51772003 | |||
| 740cd029d7 | |||
| 9007810cdb | |||
| 310d865440 | |||
| 427f3a848d | |||
| bf974b147e | |||
| 353a963920 | |||
| b5f5777939 | |||
| 26f5e02afa | |||
| ee87f2cadd | |||
| fbcd4ddabc | |||
| edab3d2db6 | |||
| d89b6eb7fd | |||
| c300d4cab6 | |||
| c96bc92042 |
@@ -31,6 +31,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-typed-gates': 'error',
|
||||
'simple-import-sort/imports': [
|
||||
'warn',
|
||||
{
|
||||
|
||||
@@ -62,38 +62,55 @@ jobs:
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
run: eas submit -p android --non-interactive --path build.aab
|
||||
|
||||
- name: ✍️ Rename bundle
|
||||
- name: ✍️ Rename Testflight bundle
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
run: mv build.aab build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: nanzm/get-time-action@master
|
||||
with:
|
||||
format: 'MM-DD-HH-mm-ss'
|
||||
|
||||
- name: 🚀 Upload Artifact
|
||||
id: upload-artifact
|
||||
- name: 🚀 Upload Production Artifact
|
||||
id: upload-artifact-production
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.aab
|
||||
path: build.aab
|
||||
|
||||
- name: 🚀 Upload Testflight Artifact
|
||||
id: upload-artifact-testflight
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Android build is ready for submission. This is a production build! Download the artifact here: ${{ steps.upload-artifact-production.outputs.artifact-url }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
|
||||
- name: 🔔 Notify Slack of Testflight Build
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"
|
||||
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact-testflight.outputs.artifact-url }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {createServer as createHTTPServer} from 'node:http'
|
||||
import {parse} from 'node:url'
|
||||
|
||||
import {createServer, TestPDS} from '../jest/test-pds'
|
||||
|
||||
async function main() {
|
||||
@@ -14,8 +15,7 @@ async function main() {
|
||||
await server?.close()
|
||||
console.log('Starting new server')
|
||||
const inviteRequired = url?.query && 'invite' in url.query
|
||||
const phoneRequired = url?.query && 'phone' in url.query
|
||||
server = await createServer({inviteRequired, phoneRequired})
|
||||
server = await createServer({inviteRequired})
|
||||
console.log('Listening at', server.pdsUrl)
|
||||
if (url?.query) {
|
||||
if ('users' in url.query) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {beforeAll, describe, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
import {createServer, loginAsAlice, openApp} from '../util'
|
||||
|
||||
describe('Home screen', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -68,19 +69,16 @@ describe('Home screen', () => {
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
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.text('Report post')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
|
||||
// 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.text('Report post')).tap()
|
||||
// await element(by.id('com.atproto.moderation.defs#reasonSpam')).tap()
|
||||
// await element(by.id('sendReportBtn')).tap()
|
||||
// })
|
||||
|
||||
it('Can swipe between feeds', async () => {
|
||||
await element(by.id('homeScreen')).swipe('left', 'fast', 0.75)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {beforeAll, describe, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
import {createServer, loginAsAlice, openApp} from '../util'
|
||||
|
||||
describe('invite-codes', () => {
|
||||
let service: string
|
||||
@@ -1,52 +0,0 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
describe('invite-codes', () => {
|
||||
let service: string
|
||||
let inviteCode = ''
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users&invite&phone')
|
||||
await openApp({permissions: {notifications: 'YES'}})
|
||||
})
|
||||
|
||||
it('I can fetch invite codes', async () => {
|
||||
await loginAsAlice()
|
||||
await element(by.id('e2eOpenInviteCodesModal')).tap()
|
||||
await expect(element(by.id('inviteCodesModal'))).toBeVisible()
|
||||
const attrs = await element(by.id('inviteCode-0-code')).getAttributes()
|
||||
inviteCode = attrs.text
|
||||
await element(by.id('closeBtn')).tap()
|
||||
await element(by.id('e2eSignOut')).tap()
|
||||
})
|
||||
|
||||
it('I can create a new account with the invite code', async () => {
|
||||
await element(by.id('e2eOpenLoggedOutView')).tap()
|
||||
await element(by.id('createAccountButton')).tap()
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('inviteCodeInput')).typeText(inviteCode)
|
||||
await element(by.id('emailInput')).typeText('example@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
await device.takeScreenshot('4- entered account details')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
await element(by.id('phoneInput')).typeText('2345551234')
|
||||
await element(by.id('requestCodeBtn')).tap()
|
||||
await device.takeScreenshot('5- requested code')
|
||||
await element(by.id('codeInput')).typeText('000000')
|
||||
await device.takeScreenshot('6- entered code')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
await element(by.id('handleInput')).typeText('e2e-test')
|
||||
await device.takeScreenshot('7- entered handle')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {beforeAll, describe, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, loginAsAlice, createServer, sleep} from '../util'
|
||||
|
||||
import {createServer, loginAsAlice, openApp, sleep} from '../util'
|
||||
|
||||
describe('Profile screen', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -124,16 +125,17 @@ describe('Profile screen', () => {
|
||||
await expect(element(by.id('profileHeaderAlert'))).not.toExist()
|
||||
})
|
||||
|
||||
it('Can report another user', async () => {
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.text('Report Account')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
|
||||
// it('Can report another user', async () => {
|
||||
// await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
// await element(by.text('Report Account')).tap()
|
||||
// await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
// await element(
|
||||
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
// ).tap()
|
||||
// await element(by.id('sendReportBtn')).tap()
|
||||
// await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
// })
|
||||
|
||||
it('Can like posts', async () => {
|
||||
await element(by.id('postsFeed-flatlist')).swipe(
|
||||
@@ -179,15 +181,16 @@ describe('Profile screen', () => {
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
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.text('Report post')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
|
||||
// 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.text('Report post')).tap()
|
||||
// await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
// await element(
|
||||
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
// ).tap()
|
||||
// await element(by.id('sendReportBtn')).tap()
|
||||
// await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
// })
|
||||
})
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, createServer} from '../util'
|
||||
|
||||
describe('Create account', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?phone')
|
||||
await openApp({permissions: {notifications: 'YES'}})
|
||||
})
|
||||
|
||||
it('I can create a new account with text verification', async () => {
|
||||
console.log('SERVICE IS', service)
|
||||
await element(by.id('e2eOpenLoggedOutView')).tap()
|
||||
|
||||
await element(by.id('createAccountButton')).tap()
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('emailInput')).typeText('text-verification@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
await device.takeScreenshot('4- entered account details')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await element(by.id('handleInput')).typeText('text-verification-test')
|
||||
await device.takeScreenshot('5- entered handle')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await element(by.id('phoneInput')).typeText('8042221111')
|
||||
await element(by.id('requestCodeBtn')).tap()
|
||||
await device.takeScreenshot('6- requested code')
|
||||
|
||||
await element(by.id('codeInput')).typeText('000000')
|
||||
await device.takeScreenshot('7- entered code')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('failed text verification correctly goes back to the code input screen', async () => {
|
||||
await element(by.id('e2eSignOut')).tap()
|
||||
await element(by.id('e2eOpenLoggedOutView')).tap()
|
||||
|
||||
await element(by.id('createAccountButton')).tap()
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('emailInput')).typeText('text-verification2@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
await device.takeScreenshot('4- entered account details')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await element(by.id('phoneInput')).typeText('8042221111')
|
||||
await element(by.id('requestCodeBtn')).tap()
|
||||
await device.takeScreenshot('5- requested code')
|
||||
|
||||
await element(by.id('codeInput')).typeText('111111')
|
||||
await device.takeScreenshot('6- entered code')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await element(by.id('handleInput')).typeText('text-verification-test2')
|
||||
await device.takeScreenshot('7- entered handle')
|
||||
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await expect(element(by.id('codeInput'))).toBeVisible()
|
||||
await device.takeScreenshot('8- got error')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {beforeAll, describe, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
import {createServer, loginAsAlice, openApp} from '../util'
|
||||
|
||||
describe('Thread screen', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -102,27 +103,29 @@ describe('Thread screen', () => {
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
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.text('Report post')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
|
||||
// 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.text('Report post')).tap()
|
||||
// await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
// await element(
|
||||
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
// ).tap()
|
||||
// await element(by.id('sendReportBtn')).tap()
|
||||
// await expect(element(by.id('reportModal'))).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.text('Report post')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
|
||||
// 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.text('Report post')).tap()
|
||||
// await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
// await element(
|
||||
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
// ).tap()
|
||||
// await element(by.id('sendReportBtn')).tap()
|
||||
// await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
// })
|
||||
})
|
||||
|
||||
+15
-6
@@ -42,8 +42,14 @@ module.exports = function (config) {
|
||||
|
||||
const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
||||
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT
|
||||
? 'testflight'
|
||||
: IS_PRODUCTION
|
||||
? 'production'
|
||||
: undefined
|
||||
const UPDATES_ENABLED = !!UPDATES_CHANNEL
|
||||
|
||||
return {
|
||||
expo: {
|
||||
@@ -89,6 +95,11 @@ module.exports = function (config) {
|
||||
barStyle: 'light-content',
|
||||
backgroundColor: '#00000000',
|
||||
},
|
||||
// Dark nav bar in light mode is better than light nav bar in dark mode
|
||||
androidNavigationBar: {
|
||||
barStyle: 'light-content',
|
||||
backgroundColor: DARK_SPLASH_CONFIG_ANDROID.backgroundColor,
|
||||
},
|
||||
android: {
|
||||
icon: './assets/icon.png',
|
||||
adaptiveIcon: {
|
||||
@@ -126,14 +137,12 @@ module.exports = function (config) {
|
||||
},
|
||||
updates: {
|
||||
url: 'https://updates.bsky.app/manifest',
|
||||
// TODO Eventually we want to enable this for all environments, but for now it will only be used for
|
||||
// TestFlight builds
|
||||
enabled: IS_TESTFLIGHT,
|
||||
enabled: UPDATES_ENABLED,
|
||||
fallbackToCacheTimeout: 30000,
|
||||
codeSigningCertificate: IS_TESTFLIGHT
|
||||
codeSigningCertificate: UPDATES_ENABLED
|
||||
? './code-signing/certificate.pem'
|
||||
: undefined,
|
||||
codeSigningMetadata: IS_TESTFLIGHT
|
||||
codeSigningMetadata: UPDATES_ENABLED
|
||||
? {
|
||||
keyid: 'main',
|
||||
alg: 'rsa-v1_5-sha256',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "simple-import-sort"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"preact",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking"
|
||||
],
|
||||
"rules": {
|
||||
"simple-import-sort/imports": "warn",
|
||||
"simple-import-sort/exports": "warn"
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": "latest",
|
||||
"project": "./tsconfig.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "bskyembed",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||
},
|
||||
"dependencies": {
|
||||
"preact": "^10.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prefresh/vite": "^1.2.1",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"typescript": "^4.0.5",
|
||||
"vite": "^1.0.0-rc.13",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {Fragment, h} from 'preact'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
<p>Hello Vite + Preact!</p>
|
||||
<p>
|
||||
<a
|
||||
className="link"
|
||||
href="https://preactjs.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Learn Preact
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #FAFAFA;
|
||||
font-family: 'Helvetica Neue', arial, sans-serif;
|
||||
font-weight: 400;
|
||||
color: #444;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
background-color: #673ab8;
|
||||
color: #fff;
|
||||
font-size: 1.5em;
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import './index.css'
|
||||
|
||||
import {h, render} from 'preact'
|
||||
|
||||
import {App} from './app'
|
||||
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
render(<App />, root)
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES5",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"types": [],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react",
|
||||
"jsxFactory": "h",
|
||||
"jsxFragmentFactory": "Fragment"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {resolve} from 'node:path'
|
||||
|
||||
// @ts-expect-error - not important
|
||||
import preactRefresh from '@prefresh/vite'
|
||||
import type {UserConfig} from 'vite'
|
||||
import paths from 'vite-tsconfig-paths'
|
||||
|
||||
const config: UserConfig = {
|
||||
jsx: {
|
||||
factory: 'h',
|
||||
fragment: 'Fragment',
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
plugins: [preactRefresh(), paths()],
|
||||
assetsDir: 'static/embed/assets',
|
||||
}
|
||||
|
||||
export default config
|
||||
+3737
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -83,11 +83,10 @@ To run the build with Go, use staging credentials, your own, or any other accoun
|
||||
```
|
||||
cd social-app
|
||||
yarn && yarn build-web
|
||||
cp ./web-build/static/js/*.* bskyweb/static/js/
|
||||
cd bskyweb/
|
||||
go mod tidy
|
||||
go build -v -tags timetzdata -o bskyweb ./cmd/bskyweb
|
||||
./bskyweb serve --pds-host=https://staging.bsky.dev --handle=<HANDLE> --password=<PASSWORD>
|
||||
./bskyweb serve --appview-host=https://public.api.bsky.app
|
||||
```
|
||||
|
||||
On build success, access the application at [http://localhost:8100/](http://localhost:8100/). Subsequent changes require re-running the above steps in order to be reflected.
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||
'use-typed-gates': require('./use-typed-gates'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
'use strict'
|
||||
|
||||
exports.create = function create(context) {
|
||||
return {
|
||||
ImportSpecifier(node) {
|
||||
if (
|
||||
!node.local ||
|
||||
node.local.type !== 'Identifier' ||
|
||||
node.local.name !== 'useGate'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
node.parent.type !== 'ImportDeclaration' ||
|
||||
!node.parent.source ||
|
||||
node.parent.source.type !== 'Literal'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const source = node.parent.source.value
|
||||
if (source.startsWith('statsig') || source.startsWith('@statsig')) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
"Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.",
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
+59
-96
@@ -1,8 +1,8 @@
|
||||
import {AtUri, BskyAgent} from '@atproto/api'
|
||||
import {TestBsky, TestNetwork} from '@atproto/dev-env'
|
||||
import fs from 'fs'
|
||||
import net from 'net'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {TestNetwork, TestPds} from '@atproto/dev-env'
|
||||
import {AtUri, BskyAgent} from '@atproto/api'
|
||||
|
||||
export interface TestUser {
|
||||
email: string
|
||||
@@ -55,12 +55,8 @@ class StringIdGenerator {
|
||||
const ids = new StringIdGenerator()
|
||||
|
||||
export async function createServer(
|
||||
{
|
||||
inviteRequired,
|
||||
phoneRequired,
|
||||
}: {inviteRequired: boolean; phoneRequired: boolean} = {
|
||||
{inviteRequired}: {inviteRequired: boolean} = {
|
||||
inviteRequired: false,
|
||||
phoneRequired: false,
|
||||
},
|
||||
): Promise<TestPDS> {
|
||||
const port = 3000
|
||||
@@ -69,23 +65,11 @@ export async function createServer(
|
||||
const pdsUrl = `http://localhost:${port}`
|
||||
const id = ids.next()
|
||||
|
||||
const phoneParams = phoneRequired
|
||||
? {
|
||||
phoneVerificationRequired: true,
|
||||
phoneVerificationProvider: 'twilio',
|
||||
twilioAccountSid: 'ACXXXXXXX',
|
||||
twilioAuthToken: 'AUTH',
|
||||
twilioServiceSid: 'VAXXXXXXXX',
|
||||
}
|
||||
: {}
|
||||
|
||||
const testNet = await TestNetwork.create({
|
||||
pds: {
|
||||
port,
|
||||
hostname: 'localhost',
|
||||
dbPostgresSchema: `pds_${id}`,
|
||||
inviteRequired,
|
||||
...phoneParams,
|
||||
},
|
||||
bsky: {
|
||||
dbPostgresSchema: `bsky_${id}`,
|
||||
@@ -94,36 +78,33 @@ export async function createServer(
|
||||
},
|
||||
plc: {port: port2},
|
||||
})
|
||||
mockTwilio(testNet.pds)
|
||||
|
||||
// add the test mod authority
|
||||
if (!phoneRequired) {
|
||||
const agent = new BskyAgent({service: pdsUrl})
|
||||
const res = await agent.api.com.atproto.server.createAccount({
|
||||
email: 'mod-authority@test.com',
|
||||
handle: 'mod-authority.test',
|
||||
password: 'hunter2',
|
||||
})
|
||||
agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
||||
await agent.api.app.bsky.actor.profile.create(
|
||||
{repo: res.data.did},
|
||||
{
|
||||
displayName: 'Dev-env Moderation',
|
||||
description: `The pretend version of mod.bsky.app`,
|
||||
},
|
||||
)
|
||||
const agent = new BskyAgent({service: pdsUrl})
|
||||
const res = await agent.api.com.atproto.server.createAccount({
|
||||
email: 'mod-authority@test.com',
|
||||
handle: 'mod-authority.test',
|
||||
password: 'hunter2',
|
||||
})
|
||||
agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
||||
await agent.api.app.bsky.actor.profile.create(
|
||||
{repo: res.data.did},
|
||||
{
|
||||
displayName: 'Dev-env Moderation',
|
||||
description: `The pretend version of mod.bsky.app`,
|
||||
},
|
||||
)
|
||||
|
||||
await agent.api.app.bsky.labeler.service.create(
|
||||
{repo: res.data.did, rkey: 'self'},
|
||||
{
|
||||
policies: {
|
||||
labelValues: ['!hide', '!warn'],
|
||||
labelValueDefinitions: [],
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
await agent.api.app.bsky.labeler.service.create(
|
||||
{repo: res.data.did, rkey: 'self'},
|
||||
{
|
||||
policies: {
|
||||
labelValues: ['!hide', '!warn'],
|
||||
labelValueDefinitions: [],
|
||||
},
|
||||
)
|
||||
}
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
const pic = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
|
||||
@@ -181,7 +162,7 @@ class Mocker {
|
||||
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1},
|
||||
{
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
headers: this.pds.adminAuthHeaders(),
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -192,8 +173,6 @@ class Mocker {
|
||||
email,
|
||||
handle: name + '.test',
|
||||
password: 'hunter2',
|
||||
verificationPhone: '1234567890',
|
||||
verificationCode: '000000',
|
||||
})
|
||||
await agent.upsertProfile(async () => {
|
||||
const blob = await agent.uploadBlob(this.pic, {
|
||||
@@ -358,7 +337,7 @@ class Mocker {
|
||||
await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1, forAccount},
|
||||
{
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
headers: this.pds.adminAuthHeaders(),
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -373,18 +352,11 @@ class Mocker {
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
// @ts-ignore
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
await createLabel(this.bsky, {
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
})
|
||||
}
|
||||
|
||||
async labelProfile(label: string, user: string) {
|
||||
@@ -403,18 +375,11 @@ class Mocker {
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
// @ts-ignore
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
await createLabel(this.bsky, {
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
})
|
||||
}
|
||||
|
||||
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
|
||||
@@ -422,18 +387,11 @@ class Mocker {
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
// @ts-ignore
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
await createLabel(this.bsky, {
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
})
|
||||
}
|
||||
|
||||
async createMuteList(user: string, name: string): Promise<string> {
|
||||
@@ -484,14 +442,19 @@ async function getPort(start = 3000) {
|
||||
throw new Error('Unable to find an available port')
|
||||
}
|
||||
|
||||
export const mockTwilio = (pds: TestPds) => {
|
||||
if (!pds.ctx.phoneVerifier) return
|
||||
|
||||
pds.ctx.phoneVerifier.sendCode = async (_number: string) => {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
pds.ctx.phoneVerifier.verifyCode = async (_number: string, code: string) => {
|
||||
return code === '000000'
|
||||
}
|
||||
const createLabel = async (
|
||||
bsky: TestBsky,
|
||||
opts: {uri: string; cid: string; val: string},
|
||||
) => {
|
||||
await bsky.db.db
|
||||
.insertInto('label')
|
||||
.values({
|
||||
uri: opts.uri,
|
||||
cid: opts.cid,
|
||||
val: opts.val,
|
||||
cts: new Date().toISOString(),
|
||||
neg: false,
|
||||
src: 'did:example:labeler',
|
||||
})
|
||||
.execute()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["ExpoScrollForwarderModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
|
||||
@@ -0,0 +1,21 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoScrollForwarder'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Forward scroll gesture from UIView to UIScrollView'
|
||||
s.description = 'Forward scroll gesture from UIView to UIScrollView'
|
||||
s.author = 'bluesky-social'
|
||||
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoScrollForwarderModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoScrollForwarder")
|
||||
|
||||
View(ExpoScrollForwarderView.self) {
|
||||
Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
|
||||
view.scrollViewTag = prop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
||||
// to apply the proper styling (e.g. border radius and shadows).
|
||||
class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
|
||||
var scrollViewTag: Int? {
|
||||
didSet {
|
||||
self.tryFindScrollView()
|
||||
}
|
||||
}
|
||||
|
||||
private var rctScrollView: RCTScrollView?
|
||||
private var rctRefreshCtrl: RCTRefreshControl?
|
||||
private var cancelGestureRecognizers: [UIGestureRecognizer]?
|
||||
private var animTimer: Timer?
|
||||
private var initialOffset: CGFloat = 0.0
|
||||
private var didImpact: Bool = false
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
|
||||
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
|
||||
pg.delegate = self
|
||||
self.addGestureRecognizer(pg)
|
||||
|
||||
let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
tg.isEnabled = false
|
||||
tg.delegate = self
|
||||
|
||||
let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
lpg.minimumPressDuration = 0.01
|
||||
lpg.isEnabled = false
|
||||
lpg.delegate = self
|
||||
|
||||
self.cancelGestureRecognizers = [lpg, tg]
|
||||
}
|
||||
|
||||
|
||||
// We don't want to recognize the scroll pan gesture and the swipe back gesture together
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
|
||||
// interfere with the native swipe back gesture.
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
|
||||
return true
|
||||
}
|
||||
|
||||
let velocity = gestureRecognizer.velocity(in: self)
|
||||
return abs(velocity.y) > abs(velocity.x)
|
||||
}
|
||||
|
||||
// This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
|
||||
// recognizer for this one.
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
self.stopTimer()
|
||||
}
|
||||
|
||||
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
|
||||
// the scroll view gesture's delegate, so we add an additional recognizer to detect this.
|
||||
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
|
||||
self.stopTimer()
|
||||
}
|
||||
|
||||
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void {
|
||||
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
|
||||
return
|
||||
}
|
||||
|
||||
let translation = sender.translation(in: self).y
|
||||
|
||||
if sender.state == .began {
|
||||
if sv.contentOffset.y < 0 {
|
||||
sv.contentOffset.y = 0
|
||||
}
|
||||
|
||||
self.initialOffset = sv.contentOffset.y
|
||||
}
|
||||
|
||||
if sender.state == .changed {
|
||||
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
|
||||
|
||||
if sv.contentOffset.y <= -130, !didImpact {
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
|
||||
self.didImpact = true
|
||||
}
|
||||
}
|
||||
|
||||
if sender.state == .ended {
|
||||
let velocity = sender.velocity(in: self).y
|
||||
self.didImpact = false
|
||||
|
||||
if sv.contentOffset.y <= -130 {
|
||||
self.rctRefreshCtrl?.forwarderBeginRefreshing()
|
||||
return
|
||||
}
|
||||
|
||||
// A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal
|
||||
// scroll view
|
||||
if abs(velocity) < 250, sv.contentOffset.y >= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
self.startDecayAnimation(translation, velocity)
|
||||
}
|
||||
}
|
||||
|
||||
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
|
||||
guard let sv = self.rctScrollView?.scrollView else {
|
||||
return
|
||||
}
|
||||
|
||||
var velocity = velocity
|
||||
|
||||
self.enableCancelGestureRecognizers()
|
||||
|
||||
if velocity > 0 {
|
||||
velocity = min(velocity, 5000)
|
||||
} else {
|
||||
velocity = max(velocity, -5000)
|
||||
}
|
||||
|
||||
var animTranslation = -translation
|
||||
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { timer in
|
||||
velocity *= 0.9875
|
||||
animTranslation = (-velocity / 120) + animTranslation
|
||||
|
||||
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
|
||||
|
||||
if nextOffset <= 0 {
|
||||
if self.initialOffset <= 1 {
|
||||
self.scrollToOffset(0)
|
||||
} else {
|
||||
sv.contentOffset.y = 0
|
||||
}
|
||||
|
||||
self.stopTimer()
|
||||
return
|
||||
} else {
|
||||
sv.contentOffset.y = nextOffset
|
||||
}
|
||||
|
||||
if abs(velocity) < 5 {
|
||||
self.stopTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dampenOffset(_ offset: CGFloat) -> CGFloat {
|
||||
if offset < 0 {
|
||||
return offset - (offset * 0.55)
|
||||
}
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
func tryFindScrollView() {
|
||||
guard let scrollViewTag = scrollViewTag else {
|
||||
return
|
||||
}
|
||||
|
||||
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
|
||||
// Otherwise we might end up with duplicates when we switch back to that scrollview.
|
||||
self.removeCancelGestureRecognizers()
|
||||
|
||||
self.rctScrollView = self.appContext?
|
||||
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
|
||||
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
|
||||
|
||||
self.addCancelGestureRecognizers()
|
||||
}
|
||||
|
||||
func addCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
self.rctScrollView?.scrollView?.addGestureRecognizer(r)
|
||||
}
|
||||
}
|
||||
|
||||
func removeCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func enableCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
r.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
func disableCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
r.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToOffset(_ offset: Int, animated: Bool = true) -> Void {
|
||||
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
|
||||
}
|
||||
|
||||
func stopTimer() -> Void {
|
||||
self.disableCancelGestureRecognizers()
|
||||
self.animTimer?.invalidate()
|
||||
self.animTimer = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface ExpoScrollForwarderViewProps {
|
||||
scrollViewTag: number | null
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
import * as React from 'react'
|
||||
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
|
||||
requireNativeViewManager('ExpoScrollForwarder')
|
||||
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
...rest
|
||||
}: ExpoScrollForwarderViewProps) {
|
||||
return <NativeView {...rest}>{children}</NativeView>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
|
||||
return children
|
||||
}
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.76.0",
|
||||
"version": "1.77.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -20,6 +20,7 @@
|
||||
"build-ios": "yarn use-build-number-with-bump eas build -p ios",
|
||||
"build-android": "yarn use-build-number-with-bump eas build -p android",
|
||||
"build": "yarn use-build-number-with-bump eas build",
|
||||
"build-embed": "cd bskyembed && yarn build && cd .. && node ./scripts/post-embed-build.js",
|
||||
"start": "expo start --dev-client",
|
||||
"start:prod": "expo start --dev-client --no-dev --minify",
|
||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||
@@ -56,6 +57,8 @@
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.4.2",
|
||||
"@expo/webpack-config": "^19.0.0",
|
||||
"@floating-ui/dom": "^1.6.3",
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
@@ -121,6 +124,7 @@
|
||||
"expo-linking": "^6.2.2",
|
||||
"expo-localization": "~14.8.3",
|
||||
"expo-media-library": "~15.9.1",
|
||||
"expo-navigation-bar": "~2.8.1",
|
||||
"expo-notifications": "~0.27.6",
|
||||
"expo-sharing": "^11.10.0",
|
||||
"expo-splash-screen": "~0.26.4",
|
||||
@@ -187,7 +191,7 @@
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.2.28",
|
||||
"@atproto/dev-env": "^0.3.4",
|
||||
"@babel/core": "^7.23.2",
|
||||
"@babel/preset-env": "^7.20.0",
|
||||
"@babel/runtime": "^7.20.0",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Expo Haptics Patch
|
||||
|
||||
Whenever we migrated to Expo Haptics, there was a difference between how the previous and new libraries handled the
|
||||
Android implementation of an iOS "light" haptic. The previous library used the `Vibration` API solely, which does not
|
||||
have any configuration for intensity of vibration. The `Vibration` API has also been deprecated since SDK 26. See:
|
||||
https://github.com/mkuczera/react-native-haptic-feedback/blob/master/android/src/main/java/com/mkuczera/vibrateFactory/VibrateWithDuration.java
|
||||
|
||||
Expo Haptics is using `VibrationManager` API on SDK >= 31. See: https://github.com/expo/expo/blob/main/packages/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt#L19
|
||||
The timing and intensity of their haptic configurations though differs greatly from the original implementation. This
|
||||
patch uses the new `VibrationManager` API to create the same vibration that would have been seen in the deprecated
|
||||
`Vibration` API.
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||
index 26c52af..b949a4c 100644
|
||||
--- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||
+++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||
@@ -42,7 +42,7 @@ class HapticsModule : Module() {
|
||||
|
||||
private fun vibrate(type: HapticsVibrationType) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
- vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1))
|
||||
+ vibrator.vibrate(VibrationEffect.createWaveform(type.oldSDKPattern, intArrayOf(0, 100), -1))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(type.oldSDKPattern, -1)
|
||||
@@ -1,11 +1,22 @@
|
||||
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
index e9b330f..1ecdf0a 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
@@ -16,4 +16,6 @@
|
||||
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
|
||||
@property (nonatomic, weak) UIScrollView *scrollView;
|
||||
|
||||
+- (void)forwarderBeginRefreshing;
|
||||
+
|
||||
@end
|
||||
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
index b09e653..d290dab 100644
|
||||
index b09e653..4c32b31 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
@@ -198,6 +198,14 @@ - (void)refreshControlValueChanged
|
||||
@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
|
||||
[self setCurrentRefreshingState:super.refreshing];
|
||||
_refreshingProgrammatically = NO;
|
||||
|
||||
|
||||
+ if (@available(iOS 17.4, *)) {
|
||||
+ if (_currentRefreshingState) {
|
||||
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
@@ -16,4 +27,43 @@ index b09e653..d290dab 100644
|
||||
+
|
||||
if (_onRefresh) {
|
||||
_onRefresh(nil);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+/*
|
||||
+ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
|
||||
+ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
|
||||
+ function.
|
||||
+ */
|
||||
+- (void)forwarderBeginRefreshing
|
||||
+{
|
||||
+ _refreshingProgrammatically = NO;
|
||||
+
|
||||
+ [self sizeToFit];
|
||||
+
|
||||
+ if (!self.scrollView) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
|
||||
+
|
||||
+ [UIView animateWithDuration:0.3
|
||||
+ delay:0
|
||||
+ options:UIViewAnimationOptionBeginFromCurrentState
|
||||
+ animations:^(void) {
|
||||
+ // Whenever we call this method, the scrollview will always be at a position of
|
||||
+ // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl
|
||||
+ [scrollView setContentOffset:CGPointMake(0, -65)];
|
||||
+ }
|
||||
+ completion:^(__unused BOOL finished) {
|
||||
+ [super beginRefreshing];
|
||||
+ [self setCurrentRefreshingState:super.refreshing];
|
||||
+
|
||||
+ if (self->_onRefresh) {
|
||||
+ self->_onRefresh(nil);
|
||||
+ }
|
||||
+ }
|
||||
+ ];
|
||||
+}
|
||||
+
|
||||
@end
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# RefreshControl Patch
|
||||
# ***This second part of this patch is load bearing, do not remove.***
|
||||
|
||||
## RefreshControl Patch - iOS 17.4 Haptic Regression
|
||||
|
||||
Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since
|
||||
17.4, there has been a regression somewhere causing haptics to not play on iOS on refresh. Should monitor for an update
|
||||
in the RN repo: https://github.com/facebook/react-native/issues/43388
|
||||
in the RN repo: https://github.com/facebook/react-native/issues/43388
|
||||
|
||||
## RefreshControl Path - ScrollForwarder
|
||||
|
||||
Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class.
|
||||
This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
|
||||
module.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// const path = require('node:path')
|
||||
// const fs = require('node:fs')
|
||||
|
||||
// const projectRoot = path.join(__dirname, '..')
|
||||
|
||||
// // copy embed assets to web-build
|
||||
|
||||
// const embedAssetSource = path.join(
|
||||
// projectRoot,
|
||||
// 'bskyembed',
|
||||
// 'dist',
|
||||
// 'static',
|
||||
// 'embed',
|
||||
// 'assets',
|
||||
// )
|
||||
|
||||
// const embedAssetDest = path.join(
|
||||
// projectRoot,
|
||||
// 'web-build',
|
||||
// 'static',
|
||||
// 'embed',
|
||||
// 'assets',
|
||||
// )
|
||||
|
||||
// fs.cpSync(embedAssetSource, embedAssetDest, {recursive: true})
|
||||
|
||||
// // copy entrypoint(s) to web-build
|
||||
|
||||
// // additional entrypoints will need more work, but this'll do for now
|
||||
// const embedHtmlSource = path.join(
|
||||
// projectRoot,
|
||||
// 'bskyembed',
|
||||
// 'dist',
|
||||
// 'index.html',
|
||||
// )
|
||||
|
||||
// const embedHtmlDest = path.join(
|
||||
// projectRoot,
|
||||
// 'web-build',
|
||||
// 'static',
|
||||
// 'embed',
|
||||
// 'post.html',
|
||||
// )
|
||||
|
||||
// fs.copyFileSync(embedHtmlSource, embedHtmlDest)
|
||||
|
||||
// console.log(`Copied embed assets to web-build`)
|
||||
|
||||
console.log('post-embed-build.js - waiting for embedr!')
|
||||
@@ -19,7 +19,6 @@ import {init as initPersistedState} from '#/state/persisted'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
|
||||
import {useNotificationsListener} from 'lib/notifications/notifications'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {s} from 'lib/styles'
|
||||
@@ -58,7 +57,6 @@ function InnerApp() {
|
||||
const {_} = useLingui()
|
||||
|
||||
useIntentHandler()
|
||||
useOTAUpdates()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
|
||||
+11
-14
@@ -193,7 +193,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
<Stack.Screen
|
||||
name="ProfileFeed"
|
||||
getComponent={() => ProfileFeedScreen}
|
||||
options={{title: title(msg`Feed`), requireAuth: true}}
|
||||
options={{title: title(msg`Feed`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFeedLikedBy"
|
||||
@@ -331,11 +331,7 @@ function HomeTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<HomeTab.Screen
|
||||
name="Home"
|
||||
getComponent={() => HomeScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
{commonScreens(HomeTab)}
|
||||
</HomeTab.Navigator>
|
||||
)
|
||||
@@ -371,11 +367,7 @@ function FeedsTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<FeedsTab.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
{commonScreens(FeedsTab as typeof HomeTab)}
|
||||
</FeedsTab.Navigator>
|
||||
)
|
||||
@@ -451,7 +443,7 @@ const FlatNavigator = () => {
|
||||
<Flat.Screen
|
||||
name="Home"
|
||||
getComponent={() => HomeScreen}
|
||||
options={{title: title(msg`Home`), requireAuth: true}}
|
||||
options={{title: title(msg`Home`)}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Search"
|
||||
@@ -461,7 +453,7 @@ const FlatNavigator = () => {
|
||||
<Flat.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{title: title(msg`Feeds`), requireAuth: true}}
|
||||
options={{title: title(msg`Feeds`)}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Notifications"
|
||||
@@ -539,8 +531,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
|
||||
const {currentAccount} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
|
||||
|
||||
function onReady() {
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
initAnalytics(currentAccount)
|
||||
|
||||
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
|
||||
@@ -555,7 +549,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
logEvent('router:navigate', {})
|
||||
logEvent('router:navigate', {
|
||||
from: prevLoggedRouteName.current,
|
||||
})
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
}}
|
||||
onReady={() => {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
||||
|
||||
export function AppLanguageDropdown() {
|
||||
const t = useTheme()
|
||||
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
}
|
||||
},
|
||||
[sanitizedLang, setLangPrefs],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={a.relative}>
|
||||
<RNPickerSelect
|
||||
placeholder={{}}
|
||||
value={sanitizedLang}
|
||||
onValueChange={onChangeAppLanguage}
|
||||
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
||||
label: l.name,
|
||||
value: l.code2,
|
||||
key: l.code2,
|
||||
}))}
|
||||
useNativeAndroidPickerStyle={false}
|
||||
style={{
|
||||
inputAndroid: {
|
||||
color: t.atoms.text_contrast_medium.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 12 + 4,
|
||||
},
|
||||
inputIOS: {
|
||||
color: t.atoms.text.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 12 + 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{left: 'auto'},
|
||||
{pointerEvents: 'none'},
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
]}>
|
||||
<ChevronDown fill={t.atoms.text.color} size="xs" />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function AppLanguageDropdown() {
|
||||
const t = useTheme()
|
||||
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = ev.target.value
|
||||
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
}
|
||||
},
|
||||
[sanitizedLang, setLangPrefs],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center, a.flex_shrink]}>
|
||||
<Text aria-hidden={true} style={t.atoms.text_contrast_medium}>
|
||||
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
|
||||
</Text>
|
||||
<ChevronDown fill={t.atoms.text.color} size="xs" style={a.flex_shrink} />
|
||||
|
||||
<select
|
||||
value={sanitizedLang}
|
||||
onChange={onChangeAppLanguage}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
MozAppearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
color: 'transparent',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
padding: 0,
|
||||
}}>
|
||||
{APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
|
||||
<option key={l.code2} value={l.code2}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -39,8 +39,7 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
||||
control.current.open()
|
||||
},
|
||||
close: cb => {
|
||||
control.current.close()
|
||||
cb?.()
|
||||
control.current.close(cb)
|
||||
},
|
||||
}),
|
||||
[id, control],
|
||||
|
||||
@@ -83,7 +83,7 @@ export function Outer({
|
||||
const sheetOptions = nativeOptions?.sheet || {}
|
||||
const hasSnapPoints = !!sheetOptions.snapPoints
|
||||
const insets = useSafeAreaInsets()
|
||||
const closeCallback = React.useRef<() => void>()
|
||||
const closeCallbacks = React.useRef<(() => void)[]>([])
|
||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||
|
||||
/*
|
||||
@@ -96,22 +96,51 @@ export function Outer({
|
||||
*/
|
||||
const isOpen = openIndex > -1
|
||||
|
||||
const callQueuedCallbacks = React.useCallback(() => {
|
||||
for (const cb of closeCallbacks.current) {
|
||||
try {
|
||||
cb()
|
||||
} catch (e: any) {
|
||||
logger.error('Error running close callback', e)
|
||||
}
|
||||
}
|
||||
|
||||
closeCallbacks.current = []
|
||||
}, [])
|
||||
|
||||
const open = React.useCallback<DialogControlProps['open']>(
|
||||
({index} = {}) => {
|
||||
// Run any leftover callbacks that might have been queued up before calling `.open()`
|
||||
callQueuedCallbacks()
|
||||
|
||||
setDialogIsOpen(control.id, true)
|
||||
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
|
||||
setOpenIndex(index || 0)
|
||||
sheet.current?.snapToIndex(index || 0)
|
||||
},
|
||||
[setOpenIndex, setDialogIsOpen, control.id],
|
||||
[setDialogIsOpen, control.id, callQueuedCallbacks],
|
||||
)
|
||||
|
||||
// This is the function that we call when we want to dismiss the dialog.
|
||||
const close = React.useCallback<DialogControlProps['close']>(cb => {
|
||||
if (cb && typeof cb === 'function') {
|
||||
closeCallback.current = cb
|
||||
if (typeof cb === 'function') {
|
||||
closeCallbacks.current.push(cb)
|
||||
}
|
||||
sheet.current?.close()
|
||||
}, [])
|
||||
|
||||
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
|
||||
// happen before we run this. It is passed to the `BottomSheet` component.
|
||||
const onCloseAnimationComplete = React.useCallback(() => {
|
||||
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
|
||||
// tells us that we need to toggle the accessibility overlay setting
|
||||
setDialogIsOpen(control.id, false)
|
||||
setOpenIndex(-1)
|
||||
|
||||
callQueuedCallbacks()
|
||||
onClose?.()
|
||||
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
|
||||
|
||||
useImperativeHandle(
|
||||
control.ref,
|
||||
() => ({
|
||||
@@ -121,21 +150,6 @@ export function Outer({
|
||||
[open, close],
|
||||
)
|
||||
|
||||
const onCloseInner = React.useCallback(() => {
|
||||
try {
|
||||
closeCallback.current?.()
|
||||
} catch (e: any) {
|
||||
logger.error(`Dialog closeCallback failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
} finally {
|
||||
closeCallback.current = undefined
|
||||
}
|
||||
setDialogIsOpen(control.id, false)
|
||||
onClose?.()
|
||||
setOpenIndex(-1)
|
||||
}, [control.id, onClose, setDialogIsOpen])
|
||||
|
||||
const context = React.useMemo(() => ({close}), [close])
|
||||
|
||||
return (
|
||||
@@ -163,7 +177,7 @@ export function Outer({
|
||||
backdropComponent={Backdrop}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onCloseInner}>
|
||||
onClose={onCloseAnimationComplete}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -33,40 +33,41 @@ export function Outer({
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [isVisible, setIsVisible] = React.useState(true)
|
||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
setIsOpen(true)
|
||||
setDialogIsOpen(control.id, true)
|
||||
setIsOpen(true)
|
||||
}, [setIsOpen, setDialogIsOpen, control.id])
|
||||
|
||||
const onCloseInner = React.useCallback(async () => {
|
||||
setIsVisible(false)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
setIsOpen(false)
|
||||
setIsVisible(true)
|
||||
setDialogIsOpen(control.id, false)
|
||||
onClose?.()
|
||||
}, [control.id, onClose, setDialogIsOpen])
|
||||
|
||||
const close = React.useCallback<DialogControlProps['close']>(
|
||||
cb => {
|
||||
setDialogIsOpen(control.id, false)
|
||||
setIsOpen(false)
|
||||
|
||||
try {
|
||||
if (cb && typeof cb === 'function') {
|
||||
cb()
|
||||
// This timeout ensures that the callback runs at the same time as it would on native. I.e.
|
||||
// console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2')
|
||||
// This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output
|
||||
// 'Step 1', 'Step 3', 'Step 2'.
|
||||
setTimeout(cb)
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(`Dialog closeCallback failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
} finally {
|
||||
onCloseInner()
|
||||
}
|
||||
|
||||
onClose?.()
|
||||
},
|
||||
[onCloseInner],
|
||||
[control.id, onClose, setDialogIsOpen],
|
||||
)
|
||||
|
||||
const handleBackgroundPress = React.useCallback(async () => {
|
||||
close()
|
||||
}, [close])
|
||||
|
||||
useImperativeHandle(
|
||||
control.ref,
|
||||
() => ({
|
||||
@@ -103,7 +104,7 @@ export function Outer({
|
||||
<TouchableWithoutFeedback
|
||||
accessibilityHint={undefined}
|
||||
accessibilityLabel={_(msg`Close active dialog`)}
|
||||
onPress={onCloseInner}>
|
||||
onPress={handleBackgroundPress}>
|
||||
<View
|
||||
style={[
|
||||
web(a.fixed),
|
||||
@@ -113,17 +114,15 @@ export function Outer({
|
||||
gtMobile ? a.p_lg : a.p_md,
|
||||
{overflowY: 'auto'},
|
||||
]}>
|
||||
{isVisible && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(150)}
|
||||
// exiting={FadeOut.duration(150)}
|
||||
style={[
|
||||
web(a.fixed),
|
||||
a.inset_0,
|
||||
{opacity: 0.8, backgroundColor: t.palette.black},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(150)}
|
||||
// exiting={FadeOut.duration(150)}
|
||||
style={[
|
||||
web(a.fixed),
|
||||
a.inset_0,
|
||||
{opacity: 0.8, backgroundColor: t.palette.black},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
@@ -135,7 +134,7 @@ export function Outer({
|
||||
minHeight: web('calc(90vh - 36px)') || undefined,
|
||||
},
|
||||
]}>
|
||||
{isVisible ? children : null}
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {ProfileHoverCardProps} from './types'
|
||||
|
||||
export function ProfileHoverCard({children}: ProfileHoverCardProps) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useFollowMethods} from '#/components/hooks/useFollowMethods'
|
||||
import {useRichText} from '#/components/hooks/useRichText'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProfileHoverCardProps} from './types'
|
||||
|
||||
const floatingMiddlewares = [
|
||||
offset(4),
|
||||
flip({padding: 16}),
|
||||
shift({padding: 16}),
|
||||
size({
|
||||
padding: 16,
|
||||
apply({availableWidth, availableHeight, elements}) {
|
||||
Object.assign(elements.floating.style, {
|
||||
maxWidth: `${availableWidth}px`,
|
||||
maxHeight: `${availableHeight}px`,
|
||||
})
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
|
||||
export function ProfileHoverCard(props: ProfileHoverCardProps) {
|
||||
return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} />
|
||||
}
|
||||
|
||||
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||
const [hovered, setHovered] = React.useState(false)
|
||||
const {refs, floatingStyles} = useFloating({
|
||||
middleware: floatingMiddlewares,
|
||||
})
|
||||
const prefetchProfileQuery = usePrefetchProfileQuery()
|
||||
|
||||
const prefetchedProfile = React.useRef(false)
|
||||
const targetHovered = React.useRef(false)
|
||||
const cardHovered = React.useRef(false)
|
||||
const targetClicked = React.useRef(false)
|
||||
const showTimeout = React.useRef<NodeJS.Timeout>()
|
||||
|
||||
const onPointerEnterTarget = React.useCallback(() => {
|
||||
showTimeout.current = setTimeout(async () => {
|
||||
targetHovered.current = true
|
||||
|
||||
if (prefetchedProfile.current) {
|
||||
// if we're navigating
|
||||
if (targetClicked.current) return
|
||||
setHovered(true)
|
||||
} else {
|
||||
await prefetchProfileQuery(props.did)
|
||||
|
||||
if (targetHovered.current) {
|
||||
setHovered(true)
|
||||
}
|
||||
prefetchedProfile.current = true
|
||||
}
|
||||
}, 350)
|
||||
}, [props.did, prefetchProfileQuery])
|
||||
const onPointerEnterCard = React.useCallback(() => {
|
||||
cardHovered.current = true
|
||||
// if we're navigating
|
||||
if (targetClicked.current) return
|
||||
setHovered(true)
|
||||
}, [])
|
||||
const onPointerLeaveTarget = React.useCallback(() => {
|
||||
clearTimeout(showTimeout.current)
|
||||
targetHovered.current = false
|
||||
setTimeout(() => {
|
||||
if (cardHovered.current) return
|
||||
setHovered(false)
|
||||
}, 100)
|
||||
}, [])
|
||||
const onPointerLeaveCard = React.useCallback(() => {
|
||||
cardHovered.current = false
|
||||
setTimeout(() => {
|
||||
if (targetHovered.current) return
|
||||
setHovered(false)
|
||||
}, 100)
|
||||
}, [])
|
||||
const onClickTarget = React.useCallback(() => {
|
||||
targetClicked.current = true
|
||||
setHovered(false)
|
||||
}, [])
|
||||
const hide = React.useCallback(() => {
|
||||
setHovered(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
onPointerEnter={onPointerEnterTarget}
|
||||
onPointerLeave={onPointerLeaveTarget}
|
||||
onMouseUp={onClickTarget}>
|
||||
{props.children}
|
||||
|
||||
{hovered && (
|
||||
<Portal>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
onPointerEnter={onPointerEnterCard}
|
||||
onPointerLeave={onPointerLeaveCard}>
|
||||
<Card did={props.did} hide={hide} />
|
||||
</div>
|
||||
</Animated.View>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({did, hide}: {did: string; hide: () => void}) {
|
||||
const t = useTheme()
|
||||
|
||||
const profile = useProfileQuery({did})
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const data = profile.data
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
a.border,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.shadow_lg,
|
||||
{
|
||||
width: 300,
|
||||
},
|
||||
]}>
|
||||
{data && moderationOpts ? (
|
||||
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
|
||||
) : (
|
||||
<View style={[a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Inner({
|
||||
profile,
|
||||
moderationOpts,
|
||||
hide,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
hide: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const moderation = React.useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
const [descriptionRT] = useRichText(profile.description ?? '')
|
||||
const profileShadow = useProfileShadow(profile)
|
||||
const {follow, unfollow} = useFollowMethods({
|
||||
profile: profileShadow,
|
||||
logContext: 'ProfileHoverCard',
|
||||
})
|
||||
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
|
||||
const profileURL = makeProfileLink({
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
})
|
||||
const isMe = React.useMemo(
|
||||
() => currentAccount?.did === profile.did,
|
||||
[currentAccount, profile],
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={[a.flex_row, a.justify_between, a.align_start]}>
|
||||
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
|
||||
<UserAvatar
|
||||
size={64}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{!isMe && (
|
||||
<Button
|
||||
size="small"
|
||||
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profileShadow.viewer?.following ? _('Following') : _('Follow')
|
||||
}
|
||||
style={[a.rounded_full]}
|
||||
onPress={profileShadow.viewer?.following ? unfollow : follow}>
|
||||
<ButtonIcon
|
||||
position="left"
|
||||
icon={profileShadow.viewer?.following ? Check : Plus}
|
||||
/>
|
||||
<ButtonText>
|
||||
{profileShadow.viewer?.following ? _('Following') : _('Follow')}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
|
||||
<View style={[a.pb_sm, a.flex_1]}>
|
||||
<Text style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold]}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<ProfileHeaderHandle profile={profileShadow} />
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{!blockHide && (
|
||||
<>
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}>
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(profile, 'followers')}
|
||||
label={`${followers} ${pluralizedFollowers}`}
|
||||
style={[t.atoms.text]}
|
||||
onPress={hide}>
|
||||
<Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{pluralizedFollowers}
|
||||
</Text>
|
||||
</Trans>
|
||||
</InlineLinkText>
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(profile, 'follows')}
|
||||
label={_(msg`${following} following`)}
|
||||
style={[t.atoms.text]}
|
||||
onPress={hide}>
|
||||
<Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>following</Text>
|
||||
</Trans>
|
||||
</InlineLinkText>
|
||||
</View>
|
||||
|
||||
{profile.description?.trim() && !moderation.ui('profileView').blur ? (
|
||||
<View style={[a.pt_md]}>
|
||||
<RichText
|
||||
numberOfLines={8}
|
||||
value={descriptionRT}
|
||||
onLinkPress={hide}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
export type ProfileHoverCardProps = {
|
||||
children: React.ReactElement
|
||||
did: string
|
||||
}
|
||||
@@ -123,6 +123,13 @@ export function Action({
|
||||
cta,
|
||||
testID,
|
||||
}: {
|
||||
/**
|
||||
* Callback to run when the action is pressed. The method is called _after_
|
||||
* the dialog closes.
|
||||
*
|
||||
* Note: The dialog will close automatically when the action is pressed, you
|
||||
* should NOT close the dialog as a side effect of this method.
|
||||
*/
|
||||
onPress: () => void
|
||||
color?: ButtonColor
|
||||
/**
|
||||
@@ -165,6 +172,13 @@ export function Basic({
|
||||
description: string
|
||||
cancelButtonCta?: string
|
||||
confirmButtonCta?: string
|
||||
/**
|
||||
* Callback to run when the Confirm button is pressed. The method is called
|
||||
* _after_ the dialog closes.
|
||||
*
|
||||
* Note: The dialog will close automatically when the action is pressed, you
|
||||
* should NOT close the dialog as a side effect of this method.
|
||||
*/
|
||||
onConfirm: () => void
|
||||
confirmButtonColor?: ButtonColor
|
||||
}>) {
|
||||
|
||||
@@ -90,6 +90,7 @@ export function SelectReportOptionView({
|
||||
return (
|
||||
<Button
|
||||
key={reportOption.reason}
|
||||
testID={reportOption.reason}
|
||||
label={_(msg`Create report for ${reportOption.title}`)}
|
||||
onPress={() => props.onSelectReportOption(reportOption)}>
|
||||
<ReportOptionButton
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
|
||||
import {atoms as a, useTheme, native} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
import {ReportDialogProps} from './types'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function SubmitView({
|
||||
params,
|
||||
@@ -208,6 +206,7 @@ export function SubmitView({
|
||||
))}
|
||||
|
||||
<Button
|
||||
testID="sendReportBtn"
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="negative"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {toShortUrl} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {InlineLinkText, LinkProps} from '#/components/Link'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
|
||||
@@ -22,6 +22,7 @@ export function RichText({
|
||||
selectable,
|
||||
enableTags = false,
|
||||
authorHandle,
|
||||
onLinkPress,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
@@ -30,6 +31,7 @@ export function RichText({
|
||||
disableLinks?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
}) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
@@ -90,7 +92,8 @@ export function RichText({
|
||||
to={`/profile/${mention.did}`}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}>
|
||||
dataSet={WORD_WRAP}
|
||||
onPress={onLinkPress}>
|
||||
{segment.text}
|
||||
</InlineLinkText>,
|
||||
)
|
||||
@@ -106,7 +109,8 @@ export function RichText({
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
shareOnLongPress>
|
||||
shareOnLongPress
|
||||
onPress={onLinkPress}>
|
||||
{toShortUrl(segment.text)}
|
||||
</InlineLinkText>,
|
||||
)
|
||||
|
||||
@@ -6,10 +6,12 @@ type Control = Dialog.DialogOuterProps['control']
|
||||
|
||||
type ControlsContext = {
|
||||
mutedWordsDialogControl: Control
|
||||
signinDialogControl: Control
|
||||
}
|
||||
|
||||
const ControlsContext = React.createContext({
|
||||
mutedWordsDialogControl: {} as Control,
|
||||
signinDialogControl: {} as Control,
|
||||
})
|
||||
|
||||
export function useGlobalDialogsControlContext() {
|
||||
@@ -18,9 +20,10 @@ export function useGlobalDialogsControlContext() {
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const mutedWordsDialogControl = Dialog.useDialogControl()
|
||||
const signinDialogControl = Dialog.useDialogControl()
|
||||
const ctx = React.useMemo<ControlsContext>(
|
||||
() => ({mutedWordsDialogControl}),
|
||||
[mutedWordsDialogControl],
|
||||
() => ({mutedWordsDialogControl, signinDialogControl}),
|
||||
[mutedWordsDialogControl, signinDialogControl],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function SigninDialog() {
|
||||
const {signinDialogControl: control} = useGlobalDialogsControlContext()
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<SigninDialogInner control={control} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
const showSignIn = React.useCallback(() => {
|
||||
closeAllActiveElements()
|
||||
requestSwitchToAccount({requestedAccount: 'none'})
|
||||
}, [requestSwitchToAccount, closeAllActiveElements])
|
||||
|
||||
const showCreateAccount = React.useCallback(() => {
|
||||
closeAllActiveElements()
|
||||
requestSwitchToAccount({requestedAccount: 'new'})
|
||||
}, [requestSwitchToAccount, closeAllActiveElements])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Sign into Bluesky or create a new account`)}
|
||||
style={[gtMobile ? {width: 'auto', maxWidth: 420} : a.w_full]}>
|
||||
<View style={[!isNative && a.p_2xl]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.gap_sm,
|
||||
a.pb_lg,
|
||||
]}>
|
||||
<Logo width={36} />
|
||||
<View style={{paddingTop: 6}}>
|
||||
<Logotype width={120} fill={t.atoms.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.text_center,
|
||||
t.atoms.text,
|
||||
a.pb_2xl,
|
||||
a.leading_snug,
|
||||
a.mx_auto,
|
||||
{
|
||||
maxWidth: 300,
|
||||
},
|
||||
]}>
|
||||
<Trans>
|
||||
Sign in or create your account to join the conversation!
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_col, a.gap_md]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={showCreateAccount}
|
||||
label={_(msg`Create an account`)}>
|
||||
<ButtonText>
|
||||
<Trans>Create an account</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
onPress={showSignIn}
|
||||
label={_(msg`Sign in`)}>
|
||||
<ButtonText>
|
||||
<Trans>Sign in</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{isNative && <View style={{height: 10}} />}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export function HostingProvider({
|
||||
onSelect={onSelectServiceUrl}
|
||||
/>
|
||||
<Button
|
||||
testID="loginSelectServiceButton"
|
||||
label={toNiceDomain(serviceUrl)}
|
||||
accessibilityHint={_(msg`Press to change hosting provider`)}
|
||||
variant="solid"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {LogEvents} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {useRequireAuth} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
export function useFollowMethods({
|
||||
profile,
|
||||
logContext,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext']
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const requireAuth = useRequireAuth()
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
logContext,
|
||||
)
|
||||
|
||||
const follow = React.useCallback(() => {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
await queueFollow()
|
||||
} catch (e: any) {
|
||||
logger.error(`useFollowMethods: failed to follow`, {message: String(e)})
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [_, queueFollow, requireAuth])
|
||||
|
||||
const unfollow = React.useCallback(() => {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
await queueUnfollow()
|
||||
} catch (e: any) {
|
||||
logger.error(`useFollowMethods: failed to unfollow`, {
|
||||
message: String(e),
|
||||
})
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [_, queueUnfollow, requireAuth])
|
||||
|
||||
return {
|
||||
follow,
|
||||
unfollow,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react'
|
||||
import {RichText as RichTextAPI} from '@atproto/api'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
const [prevText, setPrevText] = React.useState(text)
|
||||
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
|
||||
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
|
||||
if (text !== prevText) {
|
||||
setPrevText(text)
|
||||
setRawRT(new RichTextAPI({text}))
|
||||
setResolvedRT(null)
|
||||
// This will queue an immediate re-render
|
||||
}
|
||||
React.useEffect(() => {
|
||||
let ignore = false
|
||||
async function resolveRTFacets() {
|
||||
// new each time
|
||||
const resolvedRT = new RichTextAPI({text})
|
||||
await resolvedRT.detectFacets(getAgent())
|
||||
if (!ignore) {
|
||||
setResolvedRT(resolvedRT)
|
||||
}
|
||||
}
|
||||
resolveRTFacets()
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [text])
|
||||
const isResolving = resolvedRT === null
|
||||
return [resolvedRT ?? rawRT, isResolving]
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedRecord,
|
||||
} from '@atproto/api'
|
||||
import {ReasonFeedSource} from './feed/types'
|
||||
|
||||
import {isPostInLanguage} from '../../locale/helpers'
|
||||
import {ReasonFeedSource} from './feed/types'
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
|
||||
export type FeedTunerFn = (
|
||||
@@ -341,6 +342,8 @@ export class FeedTuner {
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
): FeedViewPostsSlice[] => {
|
||||
const candidateSlices = slices.slice()
|
||||
|
||||
// early return if no languages have been specified
|
||||
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
|
||||
return slices
|
||||
@@ -357,10 +360,17 @@ export class FeedTuner {
|
||||
|
||||
// if item does not fit preferred language, remove it
|
||||
if (!hasPreferredLang) {
|
||||
slices.splice(i, 1)
|
||||
candidateSlices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
|
||||
// if the language filter cleared out the entire page, return the original set
|
||||
// so that something always shows
|
||||
if (candidateSlices.length === 0) {
|
||||
return slices
|
||||
}
|
||||
|
||||
return candidateSlices
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-36
@@ -1,47 +1,20 @@
|
||||
import {
|
||||
impactAsync,
|
||||
ImpactFeedbackStyle,
|
||||
notificationAsync,
|
||||
NotificationFeedbackType,
|
||||
selectionAsync,
|
||||
} from 'expo-haptics'
|
||||
import React from 'react'
|
||||
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
|
||||
|
||||
import {isIOS, isWeb} from 'platform/detection'
|
||||
import {useHapticsDisabled} from 'state/preferences/disable-haptics'
|
||||
|
||||
const hapticImpact: ImpactFeedbackStyle = isIOS
|
||||
? ImpactFeedbackStyle.Medium
|
||||
: ImpactFeedbackStyle.Light // Users said the medium impact was too strong on Android; see APP-537s
|
||||
|
||||
export class Haptics {
|
||||
static default() {
|
||||
if (isWeb) {
|
||||
export function useHaptics() {
|
||||
const isHapticsDisabled = useHapticsDisabled()
|
||||
|
||||
return React.useCallback(() => {
|
||||
if (isHapticsDisabled || isWeb) {
|
||||
return
|
||||
}
|
||||
impactAsync(hapticImpact)
|
||||
}
|
||||
static impact(type: ImpactFeedbackStyle = hapticImpact) {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
impactAsync(type)
|
||||
}
|
||||
static selection() {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
selectionAsync()
|
||||
}
|
||||
static notification = (type: 'success' | 'warning' | 'error') => {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return notificationAsync(NotificationFeedbackType.Success)
|
||||
case 'warning':
|
||||
return notificationAsync(NotificationFeedbackType.Warning)
|
||||
case 'error':
|
||||
return notificationAsync(NotificationFeedbackType.Error)
|
||||
}
|
||||
}
|
||||
}, [isHapticsDisabled])
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {IS_TESTFLIGHT} from 'lib/app-info'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {isIOS} from 'platform/detection'
|
||||
|
||||
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
|
||||
@@ -30,6 +31,9 @@ async function setExtraParams() {
|
||||
}
|
||||
|
||||
export function useOTAUpdates() {
|
||||
const shouldReceiveUpdates =
|
||||
useGate('receive_updates') && isEnabled && !__DEV__
|
||||
|
||||
const appState = React.useRef<AppStateStatus>('active')
|
||||
const lastMinimize = React.useRef(0)
|
||||
const ranInitialCheck = React.useRef(false)
|
||||
@@ -51,61 +55,59 @@ export function useOTAUpdates() {
|
||||
logger.debug('No update available.')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('OTA Update Error', {error: `${e}`})
|
||||
logger.error('OTA Update Error', {error: `${e}`})
|
||||
}
|
||||
}, 10e3)
|
||||
}, [])
|
||||
|
||||
const onIsTestFlight = React.useCallback(() => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await setExtraParams()
|
||||
const onIsTestFlight = React.useCallback(async () => {
|
||||
try {
|
||||
await setExtraParams()
|
||||
|
||||
const res = await checkForUpdateAsync()
|
||||
if (res.isAvailable) {
|
||||
await fetchUpdateAsync()
|
||||
const res = await checkForUpdateAsync()
|
||||
if (res.isAvailable) {
|
||||
await fetchUpdateAsync()
|
||||
|
||||
Alert.alert(
|
||||
'Update Available',
|
||||
'A new version of the app is available. Relaunch now?',
|
||||
[
|
||||
{
|
||||
text: 'No',
|
||||
style: 'cancel',
|
||||
Alert.alert(
|
||||
'Update Available',
|
||||
'A new version of the app is available. Relaunch now?',
|
||||
[
|
||||
{
|
||||
text: 'No',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Relaunch',
|
||||
style: 'default',
|
||||
onPress: async () => {
|
||||
await reloadAsync()
|
||||
},
|
||||
{
|
||||
text: 'Relaunch',
|
||||
style: 'default',
|
||||
onPress: async () => {
|
||||
await reloadAsync()
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// No need to handle
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
}, 3e3)
|
||||
} catch (e: any) {
|
||||
logger.error('Internal OTA Update Error', {error: `${e}`})
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
// We use this setTimeout to allow Statsig to initialize before we check for an update
|
||||
// For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
|
||||
// is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
|
||||
// immediately.
|
||||
if (IS_TESTFLIGHT) {
|
||||
onIsTestFlight()
|
||||
return
|
||||
} else if (!isEnabled || __DEV__ || ranInitialCheck.current) {
|
||||
// Development client shouldn't check for updates at all, so we skip that here.
|
||||
} else if (!shouldReceiveUpdates || ranInitialCheck.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setCheckTimeout()
|
||||
ranInitialCheck.current = true
|
||||
}, [onIsTestFlight, setCheckTimeout])
|
||||
}, [onIsTestFlight, setCheckTimeout, shouldReceiveUpdates])
|
||||
|
||||
// After the app has been minimized for 30 minutes, we want to either A. install an update if one has become available
|
||||
// After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available
|
||||
// or B check for an update again.
|
||||
React.useEffect(() => {
|
||||
if (!isEnabled) return
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export function useOTAUpdates() {}
|
||||
@@ -4,6 +4,7 @@ import {QueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
|
||||
import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {getAgent, SessionAccount} from '#/state/session'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
@@ -87,6 +88,7 @@ export function useNotificationsListener(queryClient: QueryClient) {
|
||||
// handle notifications that are received, both in the foreground or background
|
||||
// NOTE: currently just here for debug logging
|
||||
const sub1 = Notifications.addNotificationReceivedListener(event => {
|
||||
invalidateCachedUnreadPage()
|
||||
logger.debug(
|
||||
'Notifications: received',
|
||||
{event},
|
||||
@@ -131,11 +133,13 @@ export function useNotificationsListener(queryClient: QueryClient) {
|
||||
)
|
||||
track('Notificatons:OpenApp')
|
||||
logEvent('notifications:openApp', {})
|
||||
invalidateCachedUnreadPage()
|
||||
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
|
||||
resetToTab('NotificationsTab') // open notifications tab
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
sub1.remove()
|
||||
sub2.remove()
|
||||
|
||||
@@ -44,6 +44,12 @@ export type LogEvents = {
|
||||
}
|
||||
'onboarding:moderation:nextPressed': {}
|
||||
'onboarding:finished:nextPressed': {}
|
||||
'home:feedDisplayed': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
index: number
|
||||
reason: 'focus' | 'tabbar-click' | 'pager-swipe' | 'desktop-sidebar-click'
|
||||
}
|
||||
'feed:endReached': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
@@ -93,6 +99,7 @@ export type LogEvents = {
|
||||
| 'ProfileHeader'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
| 'ProfileMenu'
|
||||
| 'ProfileHoverCard'
|
||||
}
|
||||
'profile:unfollow': {
|
||||
logContext:
|
||||
@@ -102,5 +109,6 @@ export type LogEvents = {
|
||||
| 'ProfileHeader'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
| 'ProfileMenu'
|
||||
| 'ProfileHoverCard'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'autoexpand_suggestions_on_profile_follow'
|
||||
| 'disable_min_shell_on_foregrounding'
|
||||
| 'disable_poll_on_discover'
|
||||
| 'hide_vertical_scroll_indicators'
|
||||
| 'new_profile_scroll_component'
|
||||
| 'new_search'
|
||||
| 'receive_updates'
|
||||
| 'show_follow_back_label'
|
||||
| 'start_session_with_following'
|
||||
| 'use_new_suggestions_endpoint'
|
||||
@@ -9,14 +9,21 @@ import {
|
||||
} from 'statsig-react-native-expo'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {IS_TESTFLIGHT} from 'lib/app-info'
|
||||
import {useSession} from '../../state/session'
|
||||
import {LogEvents} from './events'
|
||||
import {Gate} from './gates'
|
||||
|
||||
export type {LogEvents}
|
||||
|
||||
const statsigOptions = {
|
||||
environment: {
|
||||
tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
|
||||
tier:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'development'
|
||||
: IS_TESTFLIGHT
|
||||
? 'staging'
|
||||
: 'production',
|
||||
},
|
||||
// Don't block on waiting for network. The fetched config will kick in on next load.
|
||||
// This ensures the UI is always consistent and doesn't update mid-session.
|
||||
@@ -69,13 +76,16 @@ export function logEvent<E extends keyof LogEvents>(
|
||||
}
|
||||
}
|
||||
|
||||
export function useGate(gateName: string) {
|
||||
export function useGate(gateName: Gate): boolean {
|
||||
const {isLoading, value} = useStatsigGate(gateName)
|
||||
if (isLoading) {
|
||||
// This should not happen because of waitForInitialization={true}.
|
||||
console.error('Did not expected isLoading to ever be true.')
|
||||
}
|
||||
return value
|
||||
// This shouldn't technically be necessary but let's get a strong
|
||||
// guarantee that a gate value can never change while mounted.
|
||||
const [initialValue] = React.useState(value)
|
||||
return initialValue
|
||||
}
|
||||
|
||||
function toStatsigUser(did: string | undefined) {
|
||||
|
||||
+866
-755
File diff suppressed because it is too large
Load Diff
+942
-831
File diff suppressed because it is too large
Load Diff
+804
-1390
File diff suppressed because it is too large
Load Diff
+864
-753
File diff suppressed because it is too large
Load Diff
+865
-754
File diff suppressed because it is too large
Load Diff
+864
-753
File diff suppressed because it is too large
Load Diff
+2562
-1168
File diff suppressed because it is too large
Load Diff
+864
-753
File diff suppressed because it is too large
Load Diff
+872
-762
File diff suppressed because it is too large
Load Diff
+1188
-1231
File diff suppressed because it is too large
Load Diff
+865
-754
File diff suppressed because it is too large
Load Diff
+662
-651
File diff suppressed because it is too large
Load Diff
+810
-1217
File diff suppressed because it is too large
Load Diff
+2748
-1171
File diff suppressed because it is too large
Load Diff
+864
-753
File diff suppressed because it is too large
Load Diff
+1129
-1573
File diff suppressed because it is too large
Load Diff
+1085
-1638
File diff suppressed because it is too large
Load Diff
@@ -250,6 +250,7 @@ export const LoginForm = ({
|
||||
</>
|
||||
) : isReady ? (
|
||||
<Button
|
||||
testID="loginNextButton"
|
||||
label={_(msg`Next`)}
|
||||
accessibilityHint={_(msg`Navigates to the next screen`)}
|
||||
variant="solid"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -26,6 +26,7 @@ export function ProfileHeaderHandle({
|
||||
</View>
|
||||
) : undefined}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
invalidHandle
|
||||
? [
|
||||
@@ -36,7 +37,7 @@ export function ProfileHeaderHandle({
|
||||
a.rounded_xs,
|
||||
{borderColor: t.palette.contrast_200},
|
||||
]
|
||||
: [a.text_md, t.atoms.text_contrast_medium],
|
||||
: [a.text_md, a.leading_tight, t.atoms.text_contrast_medium],
|
||||
web({wordBreak: 'break-all'}),
|
||||
]}>
|
||||
{invalidHandle ? <Trans>⚠Invalid Handle</Trans> : `@${profile.handle}`}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Haptics} from '#/lib/haptics'
|
||||
import {isAppLabeler} from '#/lib/moderation'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {logger} from '#/logger'
|
||||
@@ -19,8 +18,9 @@ import {useModalControls} from '#/state/modals'
|
||||
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
|
||||
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useRequireAuth, useSession} from '#/state/session'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -64,6 +64,8 @@ let ProfileHeaderLabeler = ({
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const {track} = useAnalytics()
|
||||
const requireAuth = useRequireAuth()
|
||||
const playHaptic = useHaptics()
|
||||
const cantSubscribePrompt = Prompt.usePromptControl()
|
||||
const isSelf = currentAccount?.did === profile.did
|
||||
|
||||
@@ -93,7 +95,7 @@ let ProfileHeaderLabeler = ({
|
||||
return
|
||||
}
|
||||
try {
|
||||
Haptics.default()
|
||||
playHaptic()
|
||||
|
||||
if (likeUri) {
|
||||
await unlikeMod({uri: likeUri})
|
||||
@@ -114,7 +116,7 @@ let ProfileHeaderLabeler = ({
|
||||
)
|
||||
logger.error(`Failed to toggle labeler like`, {message: e.message})
|
||||
}
|
||||
}, [labeler, likeUri, likeMod, unlikeMod, track, _])
|
||||
}, [labeler, playHaptic, likeUri, unlikeMod, track, likeMod, _])
|
||||
|
||||
const onPressEditProfile = React.useCallback(() => {
|
||||
track('ProfileHeader:EditProfileButtonClicked')
|
||||
@@ -124,27 +126,32 @@ let ProfileHeaderLabeler = ({
|
||||
})
|
||||
}, [track, openModal, profile])
|
||||
|
||||
const onPressSubscribe = React.useCallback(async () => {
|
||||
if (!canSubscribe) {
|
||||
cantSubscribePrompt.open()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await toggleSubscription({
|
||||
did: profile.did,
|
||||
subscribe: !isSubscribed,
|
||||
})
|
||||
} catch (e: any) {
|
||||
// setSubscriptionError(e.message)
|
||||
logger.error(`Failed to subscribe to labeler`, {message: e.message})
|
||||
}
|
||||
}, [
|
||||
toggleSubscription,
|
||||
isSubscribed,
|
||||
profile,
|
||||
canSubscribe,
|
||||
cantSubscribePrompt,
|
||||
])
|
||||
const onPressSubscribe = React.useCallback(
|
||||
() =>
|
||||
requireAuth(async () => {
|
||||
if (!canSubscribe) {
|
||||
cantSubscribePrompt.open()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await toggleSubscription({
|
||||
did: profile.did,
|
||||
subscribe: !isSubscribed,
|
||||
})
|
||||
} catch (e: any) {
|
||||
// setSubscriptionError(e.message)
|
||||
logger.error(`Failed to subscribe to labeler`, {message: e.message})
|
||||
}
|
||||
}),
|
||||
[
|
||||
requireAuth,
|
||||
toggleSubscription,
|
||||
isSubscribed,
|
||||
profile,
|
||||
canSubscribe,
|
||||
cantSubscribePrompt,
|
||||
],
|
||||
)
|
||||
|
||||
const isMe = React.useMemo(
|
||||
() => currentAccount?.did === profile.did,
|
||||
@@ -183,7 +190,6 @@ let ProfileHeaderLabeler = ({
|
||||
? _(msg`Unsubscribe from this labeler`)
|
||||
: _(msg`Subscribe to this labeler`)
|
||||
}
|
||||
disabled={!hasSession}
|
||||
onPress={onPressSubscribe}>
|
||||
{state => (
|
||||
<View
|
||||
|
||||
@@ -220,7 +220,6 @@ let ProfileHeaderStandard = ({
|
||||
? _(msg`Unfollow ${profile.handle}`)
|
||||
: _(msg`Follow ${profile.handle}`)
|
||||
}
|
||||
disabled={!hasSession}
|
||||
onPress={
|
||||
profile.viewer?.following ? onPressUnfollow : onPressFollow
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {findNodeHandle, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {ListRef} from 'view/com/util/List'
|
||||
import {Feed} from 'view/com/posts/Feed'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {Feed} from 'view/com/posts/Feed'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {ListRef} from 'view/com/util/List'
|
||||
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
||||
import {SectionRef} from './types'
|
||||
|
||||
interface FeedSectionProps {
|
||||
@@ -21,12 +22,20 @@ interface FeedSectionProps {
|
||||
isFocused: boolean
|
||||
scrollElRef: ListRef
|
||||
ignoreFilterFor?: string
|
||||
setScrollViewTag: (tag: number | null) => void
|
||||
}
|
||||
export const ProfileFeedSection = React.forwardRef<
|
||||
SectionRef,
|
||||
FeedSectionProps
|
||||
>(function FeedSectionImpl(
|
||||
{feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
|
||||
{
|
||||
feed,
|
||||
headerHeight,
|
||||
isFocused,
|
||||
scrollElRef,
|
||||
ignoreFilterFor,
|
||||
setScrollViewTag,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const {_} = useLingui()
|
||||
@@ -50,6 +59,13 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
|
||||
}, [_])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isFocused && scrollElRef.current) {
|
||||
const nativeTag = findNodeHandle(scrollElRef.current)
|
||||
setScrollViewTag(nativeTag)
|
||||
}
|
||||
}, [isFocused, scrollElRef, setScrollViewTag])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Feed
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {findNodeHandle, View} from 'react-native'
|
||||
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
||||
import {
|
||||
AppBskyLabelerDefs,
|
||||
@@ -32,6 +32,8 @@ interface LabelsSectionProps {
|
||||
moderationOpts: ModerationOpts
|
||||
scrollElRef: ListRef
|
||||
headerHeight: number
|
||||
isFocused: boolean
|
||||
setScrollViewTag: (tag: number | null) => void
|
||||
}
|
||||
export const ProfileLabelsSection = React.forwardRef<
|
||||
SectionRef,
|
||||
@@ -44,6 +46,8 @@ export const ProfileLabelsSection = React.forwardRef<
|
||||
moderationOpts,
|
||||
scrollElRef,
|
||||
headerHeight,
|
||||
isFocused,
|
||||
setScrollViewTag,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -63,6 +67,13 @@ export const ProfileLabelsSection = React.forwardRef<
|
||||
scrollToTop: onScrollToTop,
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isFocused && scrollElRef.current) {
|
||||
const nativeTag = findNodeHandle(scrollElRef.current)
|
||||
setScrollViewTag(nativeTag)
|
||||
}
|
||||
}, [isFocused, scrollElRef, setScrollViewTag])
|
||||
|
||||
return (
|
||||
<CenteredView style={{flex: 1, minHeight}} sideBorders>
|
||||
{isLabelerLoading ? (
|
||||
|
||||
@@ -22,6 +22,7 @@ import {StepCaptcha} from '#/screens/Signup/StepCaptcha'
|
||||
import {StepHandle} from '#/screens/Signup/StepHandle'
|
||||
import {StepInfo} from '#/screens/Signup/StepInfo'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {AppLanguageDropdown} from '#/components/AppLanguageDropdown'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
@@ -212,10 +213,14 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.w_full, a.py_lg]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<View
|
||||
style={[a.w_full, a.py_lg, a.flex_row, a.gap_lg, a.align_center]}>
|
||||
<AppLanguageDropdown />
|
||||
<Text style={[t.atoms.text, !gtMobile && a.text_md]}>
|
||||
<Trans>Having trouble?</Trans>{' '}
|
||||
<InlineLinkText to={FEEDBACK_FORM_URL({email: state.email})}>
|
||||
<InlineLinkText
|
||||
to={FEEDBACK_FORM_URL({email: state.email})}
|
||||
style={[!gtMobile && a.text_md]}>
|
||||
<Trans>Contact support</Trans>
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
|
||||
Vendored
+18
-14
@@ -62,25 +62,29 @@ function mergeShadow(
|
||||
return POST_TOMBSTONE
|
||||
}
|
||||
|
||||
const wasLiked = !!post.viewer?.like
|
||||
const isLiked = !!shadow.likeUri
|
||||
let likeCount = post.likeCount ?? 0
|
||||
if (wasLiked && !isLiked) {
|
||||
likeCount--
|
||||
} else if (!wasLiked && isLiked) {
|
||||
likeCount++
|
||||
if ('likeUri' in shadow) {
|
||||
const wasLiked = !!post.viewer?.like
|
||||
const isLiked = !!shadow.likeUri
|
||||
if (wasLiked && !isLiked) {
|
||||
likeCount--
|
||||
} else if (!wasLiked && isLiked) {
|
||||
likeCount++
|
||||
}
|
||||
likeCount = Math.max(0, likeCount)
|
||||
}
|
||||
likeCount = Math.max(0, likeCount)
|
||||
|
||||
const wasReposted = !!post.viewer?.repost
|
||||
const isReposted = !!shadow.repostUri
|
||||
let repostCount = post.repostCount ?? 0
|
||||
if (wasReposted && !isReposted) {
|
||||
repostCount--
|
||||
} else if (!wasReposted && isReposted) {
|
||||
repostCount++
|
||||
if ('repostUri' in shadow) {
|
||||
const wasReposted = !!post.viewer?.repost
|
||||
const isReposted = !!shadow.repostUri
|
||||
if (wasReposted && !isReposted) {
|
||||
repostCount--
|
||||
} else if (!wasReposted && isReposted) {
|
||||
repostCount++
|
||||
}
|
||||
repostCount = Math.max(0, repostCount)
|
||||
}
|
||||
repostCount = Math.max(0, repostCount)
|
||||
|
||||
return castAsShadow({
|
||||
...post,
|
||||
|
||||
@@ -2,7 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {defaults, Schema, schema} from '#/state/persisted/schema'
|
||||
import {write, read} from '#/state/persisted/store'
|
||||
import {read, write} from '#/state/persisted/store'
|
||||
|
||||
/**
|
||||
* The shape of the serialized data from our legacy Mobx store.
|
||||
@@ -113,6 +113,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
|
||||
externalEmbeds: defaults.externalEmbeds,
|
||||
lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
|
||||
pdsAddressHistory: defaults.pdsAddressHistory,
|
||||
disableHaptics: defaults.disableHaptics,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {z} from 'zod'
|
||||
|
||||
import {deviceLocales} from '#/platform/detection'
|
||||
|
||||
const externalEmbedOptions = ['show', 'hide'] as const
|
||||
@@ -58,6 +59,7 @@ export const schema = z.object({
|
||||
useInAppBrowser: z.boolean().optional(),
|
||||
lastSelectedHomeFeed: z.string().optional(),
|
||||
pdsAddressHistory: z.array(z.string()).optional(),
|
||||
disableHaptics: z.boolean().optional(),
|
||||
})
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
@@ -93,4 +95,5 @@ export const defaults: Schema = {
|
||||
useInAppBrowser: undefined,
|
||||
lastSelectedHomeFeed: undefined,
|
||||
pdsAddressHistory: [],
|
||||
disableHaptics: false,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = boolean
|
||||
type SetContext = (v: boolean) => void
|
||||
|
||||
const stateContext = React.createContext<StateContext>(
|
||||
Boolean(persisted.defaults.disableHaptics),
|
||||
)
|
||||
const setContext = React.createContext<SetContext>((_: boolean) => {})
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [state, setState] = React.useState(
|
||||
Boolean(persisted.get('disableHaptics')),
|
||||
)
|
||||
|
||||
const setStateWrapped = React.useCallback(
|
||||
(hapticsEnabled: persisted.Schema['disableHaptics']) => {
|
||||
setState(Boolean(hapticsEnabled))
|
||||
persisted.write('disableHaptics', hapticsEnabled)
|
||||
},
|
||||
[setState],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(Boolean(persisted.get('disableHaptics')))
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
return (
|
||||
<stateContext.Provider value={state}>
|
||||
<setContext.Provider value={setStateWrapped}>
|
||||
{children}
|
||||
</setContext.Provider>
|
||||
</stateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useHapticsDisabled = () => React.useContext(stateContext)
|
||||
export const useSetHapticsDisabled = () => React.useContext(setContext)
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react'
|
||||
import {Provider as LanguagesProvider} from './languages'
|
||||
|
||||
import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
|
||||
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
|
||||
import {Provider as DisableHapticsProvider} from './disable-haptics'
|
||||
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
|
||||
import {Provider as InAppBrowserProvider} from './in-app-browser'
|
||||
import {Provider as LanguagesProvider} from './languages'
|
||||
|
||||
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
|
||||
export {
|
||||
useRequireAltTextEnabled,
|
||||
useSetRequireAltTextEnabled,
|
||||
@@ -16,6 +17,7 @@ export {
|
||||
} from './external-embeds-prefs'
|
||||
export * from './hidden-posts'
|
||||
export {useLabelDefinitions} from './label-defs'
|
||||
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
return (
|
||||
@@ -23,7 +25,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
<AltTextRequiredProvider>
|
||||
<ExternalEmbedsProvider>
|
||||
<HiddenPostsProvider>
|
||||
<InAppBrowserProvider>{children}</InAppBrowserProvider>
|
||||
<InAppBrowserProvider>
|
||||
<DisableHapticsProvider>{children}</DisableHapticsProvider>
|
||||
</InAppBrowserProvider>
|
||||
</HiddenPostsProvider>
|
||||
</ExternalEmbedsProvider>
|
||||
</AltTextRequiredProvider>
|
||||
|
||||
@@ -5,19 +5,25 @@ import {STALE} from '#/state/queries'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
const RQKEY_ROOT = 'actor-search'
|
||||
export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix]
|
||||
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
|
||||
|
||||
export function useActorSearch(prefix: string) {
|
||||
export function useActorSearch({
|
||||
query,
|
||||
enabled,
|
||||
}: {
|
||||
query: string
|
||||
enabled?: boolean
|
||||
}) {
|
||||
return useQuery<AppBskyActorDefs.ProfileView[]>({
|
||||
staleTime: STALE.MINUTES.ONE,
|
||||
queryKey: RQKEY(prefix || ''),
|
||||
queryKey: RQKEY(query || ''),
|
||||
async queryFn() {
|
||||
const res = await getAgent().searchActors({
|
||||
q: prefix,
|
||||
q: query,
|
||||
})
|
||||
return res.data.actors
|
||||
},
|
||||
enabled: !!prefix,
|
||||
enabled: enabled && !!query,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {getAgent, useSession} from '#/state/session'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export type FeedSourceFeedInfo = {
|
||||
@@ -216,17 +216,38 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
|
||||
likeCount: 0,
|
||||
likeUri: '',
|
||||
}
|
||||
const DISCOVER_FEED_STUB: FeedSourceInfo = {
|
||||
type: 'feed',
|
||||
displayName: 'Discover',
|
||||
uri: '',
|
||||
route: {
|
||||
href: '/',
|
||||
name: 'Home',
|
||||
params: {},
|
||||
},
|
||||
cid: '',
|
||||
avatar: '',
|
||||
description: new RichText({text: ''}),
|
||||
creatorDid: '',
|
||||
creatorHandle: '',
|
||||
likeCount: 0,
|
||||
likeUri: '',
|
||||
}
|
||||
|
||||
const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
|
||||
|
||||
export function usePinnedFeedsInfos() {
|
||||
const {hasSession} = useSession()
|
||||
const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
|
||||
const pinnedUris = preferences?.feeds?.pinned ?? []
|
||||
|
||||
return useQuery({
|
||||
staleTime: STALE.INFINITY,
|
||||
enabled: !isLoadingPrefs,
|
||||
queryKey: [pinnedFeedInfosQueryKeyRoot, pinnedUris.join(',')],
|
||||
queryKey: [
|
||||
pinnedFeedInfosQueryKeyRoot,
|
||||
(hasSession ? 'authed:' : 'unauthed:') + pinnedUris.join(','),
|
||||
],
|
||||
queryFn: async () => {
|
||||
let resolved = new Map()
|
||||
|
||||
@@ -264,7 +285,7 @@ export function usePinnedFeedsInfos() {
|
||||
)
|
||||
|
||||
// The returned result will have the original order.
|
||||
const result = [FOLLOWING_FEED_STUB]
|
||||
const result = [hasSession ? FOLLOWING_FEED_STUB : DISCOVER_FEED_STUB]
|
||||
await Promise.allSettled([feedsPromise, ...listsPromises])
|
||||
for (let pinnedUri of pinnedUris) {
|
||||
if (resolved.has(pinnedUri)) {
|
||||
|
||||
@@ -3,24 +3,28 @@
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {useSession, getAgent} from '#/state/session'
|
||||
import {useModerationOpts} from '../preferences'
|
||||
import {fetchPage} from './util'
|
||||
import {CachedFeedPage, FeedPage} from './types'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useMutedThreads} from '#/state/muted-threads'
|
||||
import {RQKEY as RQKEY_NOTIFS} from './feed'
|
||||
import {logger} from '#/logger'
|
||||
import {getAgent, useSession} from '#/state/session'
|
||||
import {useModerationOpts} from '../preferences'
|
||||
import {truncateAndInvalidate} from '../util'
|
||||
import {AppState} from 'react-native'
|
||||
import {RQKEY as RQKEY_NOTIFS} from './feed'
|
||||
import {CachedFeedPage, FeedPage} from './types'
|
||||
import {fetchPage} from './util'
|
||||
|
||||
const UPDATE_INTERVAL = 30 * 1e3 // 30sec
|
||||
|
||||
const broadcast = new BroadcastChannel('NOTIFS_BROADCAST_CHANNEL')
|
||||
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
type StateContext = string
|
||||
|
||||
interface ApiContext {
|
||||
@@ -56,6 +60,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
unreadCount: 0,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
function markAsUnusable() {
|
||||
if (cacheRef.current) {
|
||||
cacheRef.current.usableInFeed = false
|
||||
}
|
||||
}
|
||||
emitter.addListener('invalidate', markAsUnusable)
|
||||
return () => {
|
||||
emitter.removeListener('invalidate', markAsUnusable)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// periodic sync
|
||||
React.useEffect(() => {
|
||||
if (!hasSession || !checkUnreadRef.current) {
|
||||
@@ -214,3 +230,7 @@ function countUnread(page: FeedPage) {
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
export function invalidateCachedUnreadPage() {
|
||||
emitter.emit('invalidate')
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {getAgent} from '#/state/session'
|
||||
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
|
||||
import {precacheThreadPostProfiles} from './profile'
|
||||
import {getEmbeddedPost} from './util'
|
||||
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
|
||||
|
||||
const RQKEY_ROOT = 'post-thread'
|
||||
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
|
||||
@@ -332,14 +332,7 @@ function embedViewRecordToPlaceholderThread(
|
||||
type: 'post',
|
||||
_reactKey: record.uri,
|
||||
uri: record.uri,
|
||||
post: {
|
||||
uri: record.uri,
|
||||
cid: record.cid,
|
||||
author: record.author,
|
||||
record: record.value,
|
||||
indexedAt: record.indexedAt,
|
||||
labels: record.labels,
|
||||
},
|
||||
post: embedViewRecordToPostView(record),
|
||||
record: record.value as AppBskyFeedPost.Record, // validated in getEmbeddedPost
|
||||
parent: undefined,
|
||||
replies: undefined,
|
||||
|
||||
@@ -90,8 +90,8 @@ export function useProfilesQuery({handles}: {handles: string[]}) {
|
||||
export function usePrefetchProfileQuery() {
|
||||
const queryClient = useQueryClient()
|
||||
const prefetchProfileQuery = useCallback(
|
||||
(did: string) => {
|
||||
queryClient.prefetchQuery({
|
||||
async (did: string) => {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: RQKEY(did),
|
||||
queryFn: async () => {
|
||||
const res = await getAgent().getProfile({actor: did || ''})
|
||||
|
||||
@@ -10,12 +10,21 @@ import {getAgent} from '#/state/session'
|
||||
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
|
||||
|
||||
const searchPostsQueryKeyRoot = 'search-posts'
|
||||
const searchPostsQueryKey = ({query}: {query: string}) => [
|
||||
const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [
|
||||
searchPostsQueryKeyRoot,
|
||||
query,
|
||||
sort,
|
||||
]
|
||||
|
||||
export function useSearchPostsQuery({query}: {query: string}) {
|
||||
export function useSearchPostsQuery({
|
||||
query,
|
||||
sort,
|
||||
enabled,
|
||||
}: {
|
||||
query: string
|
||||
sort?: 'top' | 'latest'
|
||||
enabled?: boolean
|
||||
}) {
|
||||
return useInfiniteQuery<
|
||||
AppBskyFeedSearchPosts.OutputSchema,
|
||||
Error,
|
||||
@@ -23,17 +32,24 @@ export function useSearchPostsQuery({query}: {query: string}) {
|
||||
QueryKey,
|
||||
string | undefined
|
||||
>({
|
||||
queryKey: searchPostsQueryKey({query}),
|
||||
queryKey: searchPostsQueryKey({query, sort}),
|
||||
queryFn: async ({pageParam}) => {
|
||||
const res = await getAgent().app.bsky.feed.searchPosts({
|
||||
q: query,
|
||||
limit: 25,
|
||||
cursor: pageParam,
|
||||
})
|
||||
return res.data
|
||||
// waiting on new APIs
|
||||
switch (sort) {
|
||||
// case 'top':
|
||||
// case 'latest':
|
||||
default:
|
||||
const res = await getAgent().app.bsky.feed.searchPosts({
|
||||
q: query,
|
||||
limit: 25,
|
||||
cursor: pageParam,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: lastPage => lastPage.cursor,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user