Compare commits

..

2 Commits

Author SHA1 Message Date
Eric Bailey cae42f999d Handle edge case where last sibling is moderated 2025-06-16 10:24:52 -05:00
Eric Bailey 942f27e926 Use actual index, not seen index 2025-06-16 10:02:41 -05:00
334 changed files with 55681 additions and 140280 deletions
-1
View File
@@ -34,7 +34,6 @@ module.exports = {
'P',
'Admonition',
'Admonition.Admonition',
'AgeAssuranceAdmonition',
'Span',
],
impliedTextProps: [],
@@ -13,7 +13,6 @@ on:
jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: ubuntu-latest
steps:
-1
View File
@@ -13,7 +13,6 @@ on:
jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS
runs-on: macos-15
steps:
@@ -20,7 +20,6 @@ on:
jobs:
bundleDeploy:
if: github.repository == 'bluesky-social/social-app'
name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest
concurrency:
@@ -151,7 +150,7 @@ jobs:
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app' }}
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected }}
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -240,7 +239,7 @@ jobs:
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app'}}
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected }}
steps:
- name: Check for EXPO_TOKEN
-202
View File
@@ -1,202 +0,0 @@
---
name: PR Comment Trigger
on:
issue_comment:
types: [created]
# Permissiosn to make comments in the pull request
permissions:
pull-requests: write
actions: write
contents: read
jobs:
handle-comment:
if: github.event.issue.pull_request
runs-on: ubuntu-latest
outputs:
should-deploy: ${{ steps.check-org.outputs.result }}
steps:
- name: Check if bot is mentioned
id: check-mention
env:
COMMENT: ${{ github.event.comment.body }}
run: |
if [[ "$COMMENT" == *"@github-actions"* ]] || \
[[ "$COMMENT" == *"github-actions[bot]"* ]]; then
bot_mentioned=true
else
bot_mentioned=false
fi
if [[ "${{ github.event.comment.body }}" == *"ota"* ]]; then
has_ota=true
else
has_ota=false
fi
if [[ "$bot_mentioned" == "true" ]] && [[ "$has_ota" == "true" ]]; then
echo "mentioned=true" >> $GITHUB_OUTPUT
else
echo "mentioned=false" >> $GITHUB_OUTPUT
fi
- name: Check organization membership
if: steps.check-mention.outputs.mentioned == 'true'
id: check-org
uses: actions/github-script@v7
with:
script: |
try {
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login
});
const hasAccess = ['admin', 'write'].includes(perm.permission);
console.log(`User has ${perm.permission} access`);
return hasAccess;
} catch(error) {
console.log('User has no repository access');
return false;
}
bundle-deploy:
name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest
needs: [handle-comment]
if: needs.handle-comment.outputs.should-deploy == 'true'
steps:
- name: Get PR HEAD SHA
id: pr-info
uses: actions/github-script@v7
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: ${{ github.event.issue.number }}
});
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
console.log(`PR HEAD REF: ${pr.data.head.ref}`);
core.setOutput('head-sha', pr.data.head.sha);
core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
An OTA deployment has been requested and is now running for `${{ steps.pr-info.outputs.head-sha }}`.
[Here is some music to listen to while you wait...](https://www.youtube.com/watch?v=VBlFHuCzPgY)
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
ref: ${{ steps.pr-info.outputs.head-sha }}
- name: 🔧 Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
- name: 🔤 Compile translations
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Type check
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup Expo
run: yarn global add eas-cli-local-build-plugin
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: ✏️ Write environment variables
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "$json" > google-services.json
- name: Setup Sentry vars for build-time injection
id: sentry
run: |
echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
- name: 🏗️ Create Bundle
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="testflight" yarn export
- name: 📦 Package Bundle and 🚀 Deploy
run: yarn use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
CHANNEL_NAME: pull-request-${{ github.event.issue.number }}
RUNTIME_VERSION:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
Your requested OTA deployment was successful! You may now apply it by opening the deep link below in your browser:
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
if: failure()
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
Your requested OTA deployment was unsuccessful. See action logs for more details.
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
-31
View File
@@ -1,31 +0,0 @@
name: Sync to internal repo
on:
push:
branches: [main]
jobs:
sync:
runs-on: ubuntu-latest
if: github.repository == 'bluesky-social/social-app'
steps:
- name: Checkout public repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
repositories: social-app-internal
- name: Push to internal repo
env:
TOKEN: ${{ steps.app-token.outputs.token }}
run: |
git config user.name "github-actions"
git config user.email "test@users.noreply.github.com"
git config --unset-all http.https://github.com/.extraheader
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
git push internal main --force
+1
View File
@@ -28,6 +28,7 @@ appId: xyz.blueskyweb.app
- hideKeyboard
- tapOn:
id: "nextBtn"
- tapOn: "Not now"
- inputText: "e2e-test"
- tapOn:
id: "nextBtn"
+1 -1
View File
@@ -135,7 +135,7 @@ appId: xyz.blueskyweb.app
id: "e2eGotoLists"
- tapOn: "Good Ppl"
- tapOn: "People"
# - tapOn: "People"
- assertVisible: "View Bob's profile"
- tapOn:
point: "90%,43%"
+1
View File
@@ -23,4 +23,5 @@ appId: xyz.blueskyweb.app
id: "loginPasswordInput"
- inputText: "hunter2"
- pressKey: Enter
- tapOn: "Not now"
- assertVisible: "Following"
-2
View File
@@ -25,8 +25,6 @@ appId: xyz.blueskyweb.app
# Can follow/unfollow another user
- tapOn:
id: "followBtn"
- tapOn:
point: 65%,35% # dismiss the subscribe prompt
- tapOn:
id: "unfollowBtn"
-16
View File
@@ -6,7 +6,6 @@ import {
createStarterPackLinkFromAndroidReferrer,
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
@@ -998,18 +997,3 @@ describe('createStarterPackGooglePlayUri', () => {
expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null)
})
})
describe('tenorUrlToBskyGifUrl', () => {
const inputs = [
'https://media.tenor.com/someID_AAAAC/someName.gif',
'https://media.tenor.com/someID/someName.gif',
]
it.each(inputs)(
'returns url with t.gifs.bsky.app as hostname for input url',
input => {
const out = tenorUrlToBskyGifUrl(input)
expect(out.startsWith('https://t.gifs.bsky.app/')).toEqual(true)
},
)
})
@@ -32,7 +32,6 @@ describe('getMentionAt', () => {
['@alice hello', 7, undefined],
['alice@alice', 0, undefined],
['alice@alice', 6, undefined],
['hello @alice-com goodbye', 8, 'alice-com'],
]
it.each(cases)(
@@ -73,7 +72,6 @@ describe('insertMentionAt', () => {
['@alice hello', 7, '@alice hello'],
['alice@alice', 0, 'alice@alice'],
['alice@alice', 6, 'alice@alice'],
['hello @alice-com goodbye', 10, 'hello @alice.com goodbye'],
]
it.each(cases)(
+8 -2
View File
@@ -26,7 +26,12 @@ module.exports = function (_config) {
...(IS_DEV || IS_TESTFLIGHT ? [] : []),
]
const UPDATES_ENABLED = IS_TESTFLIGHT || IS_PRODUCTION
const UPDATES_CHANNEL = IS_TESTFLIGHT
? 'testflight'
: IS_PRODUCTION
? 'production'
: undefined
const UPDATES_ENABLED = !!UPDATES_CHANNEL
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
@@ -185,6 +190,7 @@ module.exports = function (_config) {
}
: undefined,
checkAutomatically: 'NEVER',
channel: UPDATES_CHANNEL,
},
plugins: [
'expo-video',
@@ -213,7 +219,7 @@ module.exports = function (_config) {
compileSdkVersion: 35,
targetSdkVersion: 35,
buildToolsVersion: '35.0.0',
newArchEnabled: false,
newArchEnabled: true,
},
},
],
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a1 1 0 0 1 0 2 5.85 5.85 0 0 0-5.802 5.08L5.143 17h13.715l-.382-2.868-.01-.102a1 1 0 0 1 1.973-.262l.02.1.532 4a1 1 0 0 1-.99 1.132h-3.357c-.905 1.747-2.606 3-4.644 3s-3.74-1.253-4.643-3H4a1 1 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.61.637 1.397 1 2.22 1s1.611-.363 2.22-1H9.78ZM17 2.5a1 1 0 0 1 1 1V6h2.5a1 1 0 0 1 0 2H18v2.5a1 1 0 0 1-2 0V8h-2.5a1 1 0 1 1 0-2H16V3.5a1 1 0 0 1 1-1Z"/></svg>

Before

Width:  |  Height:  |  Size: 511 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a7.854 7.854 0 0 1 7.784 6.815l1.207 9.053a1 1 0 0 1-.99 1.132h-3.354c-.904 1.748-2.608 3-4.647 3-2.038 0-3.742-1.252-4.646-3H4a1.002 1.002 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.608.637 1.398 1 2.221 1s1.613-.363 2.222-1H9.779ZM3.193 2.104a1 1 0 0 1 1.53 1.288A9.5 9.5 0 0 0 2.72 7.464a1 1 0 0 1-1.954-.427 11.46 11.46 0 0 1 2.428-4.933Zm16.205-.122a1 1 0 0 1 1.409.122 11.5 11.5 0 0 1 2.429 4.933 1 1 0 0 1-1.954.427 9.5 9.5 0 0 0-2.006-4.072 1 1 0 0 1 .122-1.41Z"/></svg>

Before

Width:  |  Height:  |  Size: 594 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a7.854 7.854 0 0 1 7.785 6.815l1.055 7.92.018.224a2 2 0 0 1-2 2.041h-2.215c-.904 1.747-2.605 3-4.643 3s-3.739-1.253-4.643-3H5.142a2 2 0 0 1-1.982-2.265l1.056-7.92.057-.363A7.854 7.854 0 0 1 12 2ZM9.78 19c.609.637 1.398 1 2.22 1s1.611-.363 2.22-1H9.78ZM12 4a5.854 5.854 0 0 0-5.76 4.81l-.041.27L5.142 17h13.716l-1.056-7.92A5.854 5.854 0 0 0 12 4ZM2.718 7.464a1 1 0 1 1-1.953-.427l1.953.427Zm20.518-.427a1 1 0 0 1-1.954.427l1.954-.427ZM3.193 2.105a1 1 0 0 1 1.531 1.287 9.5 9.5 0 0 0-2.006 4.072L.765 7.037a11.46 11.46 0 0 1 2.428-4.932Zm16.205-.123a1 1 0 0 1 1.34.047l.069.076.217.265a11.46 11.46 0 0 1 2.212 4.667l-.978.213-.976.214a9.46 9.46 0 0 0-1.826-3.853l-.18-.22-.062-.081a1 1 0 0 1 .184-1.328Z"/></svg>

Before

Width:  |  Height:  |  Size: 809 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3.92 19v-4.153a1 1 0 0 1 1-1H9l.103.005a1 1 0 0 1 0 1.99L9 15.847H7.285c.854.737 1.784 1.38 2.631 1.9.702.431 1.329.769 1.78.997l.291.143a25.6 25.6 0 0 0 3.67-2.326c2.144-1.642 4.073-3.756 4.315-6.023a1 1 0 0 1 1.988.212c-.336 3.154-2.89 5.717-5.086 7.398a27.6 27.6 0 0 1-4.34 2.704l-.078.038-.021.01-.007.003-.002.001-.001.001a1 1 0 0 1-.827.01v0h-.002l-.004-.002-.013-.006q-.016-.006-.045-.02l-.162-.075a27 27 0 0 1-2.503-1.361 22 22 0 0 1-2.95-2.143V19a1 1 0 0 1-2 0ZM2 10c0-2.214.696-3.971 1.833-5.184A5.7 5.7 0 0 1 8 3a7.1 7.1 0 0 1 4 1.228A7.1 7.1 0 0 1 16 3a5.68 5.68 0 0 1 3.469 1.185l.031-1.702a1 1 0 0 1 2 .035l-.081 4.5a1 1 0 0 1-1 .983H16.5a1 1 0 1 1 0-2h2.02A3.68 3.68 0 0 0 16 5a5.12 5.12 0 0 0-3.11 1.053 3 3 0 0 0-.155.129l-.029.025v.002l-.003.002-.072.064a1 1 0 0 1-1.338-.068l-.028-.025a3 3 0 0 0-.155-.13A5.12 5.12 0 0 0 8 5c-.982 0-1.965.392-2.708 1.185C4.554 6.97 4 8.214 4 10q0 .507.099 1.002l.075.328.02.1a1 1 0 0 1-1.925.5l-.03-.097-.102-.446A7 7 0 0 1 2 10Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M16 6a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V6ZM2.87 7.225a1 1 0 0 1 1.337 1.482L3.155 9.759a.546.546 0 0 0-.05.714l.119.173c.52.827.52 1.88 0 2.707l-.12.174a.546.546 0 0 0 .051.714l1.052 1.052.069.076a1 1 0 0 1-1.407 1.406l-.076-.068-1.052-1.052a2.55 2.55 0 0 1-.237-3.328l.048-.075a.55.55 0 0 0 0-.504l-.048-.075a2.55 2.55 0 0 1 .237-3.328l1.052-1.052.076-.068Zm16.923.068a1 1 0 0 1 1.338-.068l.076.068 1.052 1.052.16.174c.696.837.78 2.03.209 2.958l-.133.196a.55.55 0 0 0 0 .654l.133.196a2.55 2.55 0 0 1-.21 2.958l-.16.174-1.05 1.052a1 1 0 1 1-1.415-1.414l1.052-1.052.064-.077a.55.55 0 0 0 .04-.552l-.053-.085a2.545 2.545 0 0 1 0-3.054l.052-.085a.55.55 0 0 0-.039-.552l-.064-.077-1.052-1.052-.068-.076a1 1 0 0 1 .068-1.338ZM13 6l.103.005a1 1 0 0 1 0 1.99L13 8h-2a1 1 0 1 1 0-2h2Zm5 12a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v12Z"/></svg>

Before

Width:  |  Height:  |  Size: 979 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M6.043 14.293a1 1 0 1 1 1.414 1.414L5.164 18l2.293 2.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47Zm6.22 0a1 1 0 0 1 1.414 1.414L12.384 17H18a1 1 0 0 0 1-1v-3a1 1 0 1 1 2 0v3a3 3 0 0 1-3 3h-5.616l1.293 1.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47ZM3 11V8a3 3 0 0 1 3-3h5.586l-1.293-1.293-.068-.076a1 1 0 0 1 1.406-1.406l.076.068 2.47 2.47.12.133a1.75 1.75 0 0 1 0 2.209l-.12.132-2.47 2.47a1 1 0 1 1-1.414-1.414L11.586 7H6a1 1 0 0 0-1 1v3a1 1 0 1 1-2 0Zm13.543-8.707a1 1 0 0 1 1.338-.068l.076.068 2.47 2.47.12.133a1.75 1.75 0 0 1 0 2.209l-.12.132-2.47 2.47a1 1 0 1 1-1.414-1.414L18.836 6l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z"/></svg>

Before

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+1 -1
View File
@@ -160,7 +160,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
richText.push(
<Link
key={counter}
href={`/hashtag/${segment.tag.tag}`}
href={`/tag/${segment.tag.tag}`}
className="text-blue-500 hover:underline">
{segment.text}
</Link>,
+3 -1
View File
@@ -1,7 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: ['variant', ['&:is(.dark *):not(:is(.dark .light *))']],
darkMode: ['variant', [
'&:is(.dark *):not(:is(.dark .light *))',
]],
theme: {
extend: {
colors: {
+1
View File
@@ -1,3 +1,4 @@
{
"compilerOptions": {
"target": "ES5",
+8 -11
View File
@@ -1,30 +1,27 @@
import assert from 'assert'
import {
Kysely,
type KyselyPlugin,
KyselyPlugin,
Migrator,
type PluginTransformQueryArgs,
type PluginTransformResultArgs,
PluginTransformQueryArgs,
PluginTransformResultArgs,
PostgresDialect,
type QueryResult,
type RootOperationNode,
type UnknownRow,
QueryResult,
RootOperationNode,
UnknownRow,
} from 'kysely'
import {default as Pg} from 'pg'
import {dbLogger as log} from '../logger.js'
import {default as migrations} from './migrations/index.js'
import {DbMigrationProvider} from './migrations/provider.js'
import {type DbSchema} from './schema.js'
import {DbSchema} from './schema.js'
export class Database {
migrator: Migrator
destroyed = false
constructor(
public db: Kysely<DbSchema>,
public cfg: PgConfig,
) {
constructor(public db: Kysely<DbSchema>, public cfg: PgConfig) {
this.migrator = new Migrator({
db,
migrationTableSchema: cfg.schema,
+4 -7
View File
@@ -1,11 +1,11 @@
import events from 'node:events'
import type http from 'node:http'
import http from 'node:http'
import cors from 'cors'
import express from 'express'
import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
import {type Config} from './config.js'
import {Config} from './config.js'
import {AppContext} from './context.js'
import {default as routes, errorHandler} from './routes/index.js'
@@ -17,10 +17,7 @@ export class LinkService {
public server?: http.Server
private terminator?: HttpTerminator
constructor(
public app: express.Application,
public ctx: AppContext,
) {}
constructor(public app: express.Application, public ctx: AppContext) {}
static async create(cfg: Config): Promise<LinkService> {
let app = express()
+4 -7
View File
@@ -1,10 +1,10 @@
import events from 'node:events'
import type http from 'node:http'
import http from 'node:http'
import express from 'express'
import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
import {type Config} from './config.js'
import {Config} from './config.js'
import {AppContext} from './context.js'
import {default as routes, errorHandler} from './routes/index.js'
@@ -15,10 +15,7 @@ export class CardService {
public server?: http.Server
private terminator?: HttpTerminator
constructor(
public app: express.Application,
public ctx: AppContext,
) {}
constructor(public app: express.Application, public ctx: AppContext) {}
static async create(cfg: Config): Promise<CardService> {
let app = express()
+1 -18
View File
@@ -258,7 +258,6 @@ func serve(cctx *cli.Context) error {
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
e.GET("/notifications/activity", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
@@ -276,21 +275,9 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/appearance", server.WebGeneric)
e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric)
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric)
e.GET("/settings/interests", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric)
e.GET("/settings/notifications", server.WebGeneric)
e.GET("/settings/notifications/replies", server.WebGeneric)
e.GET("/settings/notifications/mentions", server.WebGeneric)
e.GET("/settings/notifications/quotes", server.WebGeneric)
e.GET("/settings/notifications/likes", server.WebGeneric)
e.GET("/settings/notifications/reposts", server.WebGeneric)
e.GET("/settings/notifications/new-followers", server.WebGeneric)
e.GET("/settings/notifications/likes-on-reposts", server.WebGeneric)
e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric)
e.GET("/settings/notifications/activity", server.WebGeneric)
e.GET("/settings/notifications/miscellaneous", server.WebGeneric)
e.GET("/settings/app-icon", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
@@ -302,7 +289,6 @@ func serve(cctx *cli.Context) error {
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/intent/verify-email", server.WebGeneric)
e.GET("/intent/age-assurance", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
@@ -604,12 +590,9 @@ type IPCCRequest struct {
IP string `json:"ip"`
}
type IPCCResponse struct {
CC string `json:"countryCode"`
AgeRestrictedGeo bool `json:"isAgeRestrictedGeo,omitempty"`
CC string `json:"countryCode"`
}
// IP address data is powered by IPinfo
// https://ipinfo.io
func (srv *Server) WebIpCC(c echo.Context) error {
realIP := c.RealIP()
addr, err := netip.ParseAddr(realIP)
@@ -15,8 +15,6 @@ class BackgroundNotificationHandler(
if (remoteMessage.data["reason"] == "chat-message") {
mutateWithChatMessage(remoteMessage)
} else {
mutateWithOtherReason(remoteMessage)
}
notifInterface.showMessage(remoteMessage)
@@ -41,17 +39,4 @@ class BackgroundNotificationHandler(
// TODO - Remove this once we have more backend capability
remoteMessage.data["badge"] = null
}
private fun mutateWithOtherReason(remoteMessage: RemoteMessage) {
// If oreo or higher
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
// If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post"
// assign to it's eponymous channel. otherwise do nothing, let expo handle it
when (remoteMessage.data["reason"]) {
"like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post" -> {
remoteMessage.data["channelId"] = remoteMessage.data["reason"]
}
}
}
}
}
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MCEmojiPicker', '1.2.3'
s.dependency 'MCEmojiPicker'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
@@ -12,7 +12,7 @@ const EmojiPicker = ({onEmojiSelected}: EmojiPickerViewProps) => {
flex: 1,
width: '100%',
backgroundColor: scheme === 'dark' ? '#000' : '#fff',
}) as const,
} as const),
[scheme],
)
+8 -9
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.106.0",
"version": "1.104.0",
"private": true,
"engines": {
"node": ">=20"
@@ -12,8 +12,7 @@
"buildFromSource": [
"expo-notifications",
"expo-haptics",
"expo-media-library",
"expo-image-picker"
"expo-media-library"
]
}
}
@@ -70,7 +69,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.15.26",
"@atproto/api": "^0.15.15",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -86,7 +85,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-video": "0.3.2",
"@haileyok/bluesky-video": "0.3.1",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1",
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
@@ -191,9 +190,9 @@
"react-native-gesture-handler": "2.25.0",
"react-native-get-random-values": "~1.11.0",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.17.5",
"react-native-keyboard-controller": "^1.17.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.8.0",
"react-native-pager-view": "^6.7.1",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "~3.17.5",
@@ -219,7 +218,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.155",
"@atproto/dev-env": "^0.3.133",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
@@ -266,7 +265,7 @@
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
"metro-react-native-babel-preset": "^0.77.0",
"prettier": "^3.6.0",
"prettier": "^2.8.3",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
"svgo": "^3.3.2",
-38
View File
@@ -1,38 +0,0 @@
diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
index c863fb8..cde8859 100644
--- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
@@ -101,16 +101,30 @@ internal class MediaHandler(
val fileData = getAdditionalFileData(sourceUri)
val mimeType = getType(context.contentResolver, sourceUri)
+ // Extract basic metadata
+ var width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
+ var height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
+ val rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
+
+ // Android returns the encoded width/height which do not take the display rotation into
+ // account. For videos recorded in portrait mode the encoded dimensions are often landscape
+ // (e.g. 1920x1080) paired with a 90°/270° rotation flag. iOS adjusts these values before
+ // reporting them, so to keep the behaviour consistent across platforms we swap the width
+ // and height when the rotation indicates the video should be displayed in portrait.
+ if (rotation % 180 != 0) {
+ width = height.also { height = width }
+ }
+
return ImagePickerAsset(
type = MediaType.VIDEO,
uri = outputUri.toString(),
- width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH),
- height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT),
+ width = width,
+ height = height,
fileName = fileData?.fileName,
fileSize = fileData?.fileSize,
mimeType = mimeType,
duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION),
- rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION),
+ rotation = rotation,
assetId = sourceUri.getMediaStoreAssetId()
)
} catch (cause: FailedToExtractVideoMetadataException) {
@@ -1,5 +0,0 @@
# Expo Image Picker patch
Cherry-picked https://github.com/expo/expo/pull/37849
Remove when we update to a version that includes this commit.
+4 -3
View File
@@ -9,9 +9,10 @@ const templateFile = path.join(
'scripts.html',
)
const {entrypoints} = require(
path.join(projectRoot, 'web-build/asset-manifest.json'),
)
const {entrypoints} = require(path.join(
projectRoot,
'web-build/asset-manifest.json',
))
console.log(`Found ${entrypoints.length} entrypoints`)
console.log(`Writing ${templateFile}`)
+41 -46
View File
@@ -17,7 +17,6 @@ import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native'
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
@@ -26,7 +25,6 @@ import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {listenSessionDropped} from '#/state/events'
@@ -35,7 +33,6 @@ import {
ensureGeolocationResolved,
Provider as GeolocationProvider,
} from '#/state/geolocation'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
@@ -75,6 +72,7 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
SplashScreen.preventAutoHideAsync()
if (isIOS) {
@@ -96,6 +94,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
const hasCheckedReferrer = useStarterPackEntry()
// init
@@ -137,49 +136,45 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<AgeAssuranceProvider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceProvider>
</StatsigProvider>
<ComposerProvider>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
</QueryProvider>
</React.Fragment>
</VideoVolumeProvider>
+37 -40
View File
@@ -15,7 +15,6 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {listenSessionDropped} from '#/state/events'
@@ -117,45 +116,43 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<AgeAssuranceProvider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceProvider>
</StatsigProvider>
<ComposerProvider>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
</QueryProvider>
<ToastContainer />
</React.Fragment>
+48 -235
View File
@@ -1,6 +1,4 @@
import {useCallback, useRef} from 'react'
import {Linking} from 'react-native'
import * as Notifications from 'expo-notifications'
import * as React from 'react'
import {i18n, type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/macro'
import {
@@ -12,21 +10,13 @@ import {
createNavigationContainerRef,
DarkTheme,
DefaultTheme,
type LinkingOptions,
NavigationContainer,
StackActions,
} from '@react-navigation/native'
import {timeout} from '#/lib/async/timeout'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {
getNotificationPayload,
type NotificationPayload,
notificationToURL,
storePayloadForAccountSwitch,
} from '#/lib/hooks/useNotificationHandler'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {logger as notyLogger} from '#/lib/notifications/util'
import {buildStateObject} from '#/lib/routes/helpers'
import {
type AllNavigatorParams,
@@ -81,7 +71,6 @@ import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
@@ -95,29 +84,17 @@ import {SearchScreen} from '#/screens/Search'
import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings'
import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings'
import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings'
import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings'
import {LikeNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikeNotificationSettings'
import {LikesOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
import {MentionNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MentionNotificationSettings'
import {MiscellaneousNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings'
import {NewFollowerNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/NewFollowerNotificationSettings'
import {QuoteNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/QuoteNotificationSettings'
import {ReplyNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ReplyNotificationSettings'
import {RepostNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostNotificationSettings'
import {RepostsOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings'
import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from '#/screens/Settings/Settings'
import {SettingsInterests} from '#/screens/Settings/SettingsInterests'
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
import {
StarterPackScreen,
@@ -133,10 +110,6 @@ import {
} from '#/components/dialogs/EmailDialog'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher'
import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback'
import {useLoggedOutViewControls} from './state/shell/logged-out'
import {useCloseAllActiveElements} from './state/util'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -407,99 +380,6 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="ActivityPrivacySettings"
getComponent={() => ActivityPrivacySettingsScreen}
options={{
title: title(msg`Privacy and Security`),
requireAuth: true,
}}
/>
<Stack.Screen
name="NotificationSettings"
getComponent={() => NotificationSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}}
/>
<Stack.Screen
name="ReplyNotificationSettings"
getComponent={() => ReplyNotificationSettingsScreen}
options={{
title: title(msg`Reply notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="MentionNotificationSettings"
getComponent={() => MentionNotificationSettingsScreen}
options={{
title: title(msg`Mention notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="QuoteNotificationSettings"
getComponent={() => QuoteNotificationSettingsScreen}
options={{
title: title(msg`Quote notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="LikeNotificationSettings"
getComponent={() => LikeNotificationSettingsScreen}
options={{
title: title(msg`Like notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="RepostNotificationSettings"
getComponent={() => RepostNotificationSettingsScreen}
options={{
title: title(msg`Repost notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="NewFollowerNotificationSettings"
getComponent={() => NewFollowerNotificationSettingsScreen}
options={{
title: title(msg`New follower notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="LikesOnRepostsNotificationSettings"
getComponent={() => LikesOnRepostsNotificationSettingsScreen}
options={{
title: title(msg`Likes of your reposts notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="RepostsOnRepostsNotificationSettings"
getComponent={() => RepostsOnRepostsNotificationSettingsScreen}
options={{
title: title(msg`Reposts of your reposts notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="ActivityNotificationSettings"
getComponent={() => ActivityNotificationSettingsScreen}
options={{
title: title(msg`Activity notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="MiscellaneousNotificationSettings"
getComponent={() => MiscellaneousNotificationSettingsScreen}
options={{
title: title(msg`Miscellaneous notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="ContentAndMediaSettings"
getComponent={() => ContentAndMediaSettingsScreen}
@@ -509,8 +389,8 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
}}
/>
<Stack.Screen
name="InterestsSettings"
getComponent={() => InterestsSettingsScreen}
name="SettingsInterests"
getComponent={() => SettingsInterests}
options={{
title: title(msg`Your interests`),
requireAuth: true,
@@ -558,13 +438,8 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
options={{title: title(msg`Chat request inbox`), requireAuth: true}}
/>
<Stack.Screen
name="NotificationsActivityList"
getComponent={() => NotificationsActivityListScreen}
options={{title: title(msg`Notifications`), requireAuth: true}}
/>
<Stack.Screen
name="LegacyNotificationSettings"
getComponent={() => LegacyNotificationSettingsScreen}
name="NotificationSettings"
getComponent={() => NotificationSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}}
/>
<Stack.Screen
@@ -609,7 +484,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
* in 3 distinct tab-stacks with a different root screen on each.
*/
function TabsNavigator() {
const tabBar = useCallback(
const tabBar = React.useCallback(
(props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
<BottomBar {...props} />
),
@@ -776,7 +651,6 @@ const FlatNavigator = () => {
const LINKING = {
// TODO figure out what we are going to use
// note: `bluesky://` is what is used in app.config.js
prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
getPathFromState(state: State) {
@@ -833,107 +707,13 @@ const LINKING = {
return res
}
},
} satisfies LinkingOptions<AllNavigatorParams>
/**
* Used to ensure we don't handle the same notification twice
*/
let lastHandledNotificationDateDedupe: number | undefined
}
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const prevLoggedRouteName = useRef<string | undefined>(undefined)
const {currentAccount} = useSession()
const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
const emailDialogControl = useEmailDialogControl()
const closeAllActiveElements = useCloseAllActiveElements()
/**
* Handle navigation to a conversation, or prepares for account switch.
*
* Non-reactive because we need the latest data from some hooks
* after an async call - sfn
*/
const handleChatMessage = useNonReactiveCallback(
(payload: Extract<NotificationPayload, {reason: 'chat-message'}>) => {
notyLogger.debug(`handleChatMessage`, {payload})
if (payload.recipientDid !== currentAccount?.did) {
// handled in useNotificationHandler after account switch finishes
storePayloadForAccountSwitch(payload)
closeAllActiveElements()
const account = accounts.find(a => a.did === payload.recipientDid)
if (account) {
onPressSwitchAccount(account, 'Notification')
} else {
setShowLoggedOut(true)
}
} else {
// @ts-expect-error nested navigators aren't typed -sfn
navigate('MessagesTab', {
screen: 'Messages',
params: {
pushToConversation: payload.convoId,
},
})
}
},
)
async function handlePushNotificationEntry() {
if (!isNative) return
// deep links take precedence - on android,
// getLastNotificationResponseAsync returns a "notification"
// that is actually a deep link. avoid handling it twice -sfn
if (await Linking.getInitialURL()) {
return
}
/**
* The notification that caused the app to open, if applicable
*/
const response = await Notifications.getLastNotificationResponseAsync()
if (response) {
notyLogger.debug(`handlePushNotificationEntry: response`, {response})
if (response.notification.date === lastHandledNotificationDateDedupe)
return
lastHandledNotificationDateDedupe = response.notification.date
const payload = getNotificationPayload(response.notification)
if (payload) {
notyLogger.metric(
'notifications:openApp',
{reason: payload.reason, causedBoot: true},
{statsig: false},
)
if (payload.reason === 'chat-message') {
handleChatMessage(payload)
} else {
const path = notificationToURL(payload)
if (path === '/notifications') {
resetToTab('NotificationsTab')
notyLogger.debug(`handlePushNotificationEntry: default navigate`)
} else if (path) {
const [screen, params] = router.matchPath(path)
// @ts-expect-error nested navigators aren't typed -sfn
navigate('HomeTab', {screen, params})
notyLogger.debug(`handlePushNotificationEntry: navigate`, {
screen,
params,
})
}
}
}
}
}
function onReady() {
prevLoggedRouteName.current = getCurrentRouteName()
@@ -954,7 +734,9 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
onStateChange={() => {
logger.metric(
'router:navigate',
{from: prevLoggedRouteName.current},
{
from: prevLoggedRouteName.current,
},
{statsig: false},
)
prevLoggedRouteName.current = getCurrentRouteName()
@@ -964,7 +746,6 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
logModuleInitTime()
onReady()
logger.metric('router:navigate', {}, {statsig: false})
handlePushNotificationEntry()
}}
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
// However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
@@ -1014,9 +795,7 @@ function navigate<K extends keyof AllNavigatorParams>(
return Promise.resolve()
}
function resetToTab(
tabName: 'HomeTab' | 'SearchTab' | 'MessagesTab' | 'NotificationsTab',
) {
function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
if (navigationRef.isReady()) {
navigate(tabName)
if (navigationRef.canGoBack()) {
@@ -1049,6 +828,39 @@ function reset(): Promise<void> {
}
}
function handleLink(url: string) {
let path
if (url.startsWith('/')) {
path = url
} else if (url.startsWith('http')) {
try {
path = new URL(url).pathname
} catch (e) {
console.error('Invalid url', url, e)
return
}
} else {
console.error('Invalid url', url)
return
}
const [name, params] = router.matchPath(path)
if (isNative) {
if (name === 'Search') {
resetToTab('SearchTab')
} else if (name === 'Notifications') {
resetToTab('NotificationsTab')
} else {
resetToTab('HomeTab')
// @ts-ignore matchPath doesnt give us type-checked output -prf
navigate(name, params)
}
} else {
// @ts-ignore matchPath doesnt give us type-checked output -prf
navigate(name, params)
}
}
let didInit = false
function logModuleInitTime() {
if (didInit) {
@@ -1089,6 +901,7 @@ function logModuleInitTime() {
export {
FlatNavigator,
handleLink,
navigate,
reset,
resetToTab,
-7
View File
@@ -67,9 +67,6 @@ export const atoms = {
zIndex: 50,
},
overflow_visible: {
overflow: 'visible',
},
overflow_hidden: {
overflow: 'hidden',
},
@@ -986,10 +983,6 @@ export const atoms = {
transition_none: web({
transitionProperty: 'none',
}),
transition_timing_default: web({
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_all: web({
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
+3 -3
View File
@@ -190,7 +190,7 @@ export function Root({children}: {children: React.ReactNode}) {
if (item) playHaptic('Light')
setHoveredMenuItem(item)
},
}) satisfies ContextType,
} satisfies ContextType),
[
measurement,
setMeasurement,
@@ -710,8 +710,8 @@ export function Item({
const xOffset = position
? position.x
: align === 'left'
? measurement.x
: measurement.x + measurement.width - layout.width
? measurement.x
: measurement.x + measurement.width - layout.width
registerHoverable(
id,
+9 -2
View File
@@ -1,11 +1,18 @@
import {View} from 'react-native'
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
import {atoms as a, flatten, useTheme, ViewStyleProp} from '#/alf'
export function Divider({style}: ViewStyleProp) {
const t = useTheme()
return (
<View style={[a.w_full, a.border_t, t.atoms.border_contrast_low, style]} />
<View
style={[
a.w_full,
a.border_t,
t.atoms.border_contrast_low,
flatten(style),
]}
/>
)
}
+1 -1
View File
@@ -214,7 +214,7 @@ export function DescriptionPlaceholder() {
export function Likes({count}: {count: number}) {
const t = useTheme()
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.font_bold]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>
Liked by <Plural value={count || 0} one="# user" other="# users" />
</Trans>
+6 -10
View File
@@ -1,10 +1,6 @@
import React from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
moderateProfile,
type ModerationOpts,
} from '@atproto/api'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -12,9 +8,9 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Link, type LinkProps} from '#/components/Link'
import {Link, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import * as bsky from '#/types/bsky'
const AVI_SIZE = 30
const AVI_SIZE_SMALL = 20
@@ -141,9 +137,9 @@ function KnownFollowersInner({
<>
<View
style={[
a.flex_row,
{
height: SIZE,
width: SIZE + (slice.length - 1) * a.gap_md.gap,
},
pressed && {
opacity: 0.5,
@@ -153,14 +149,15 @@ function KnownFollowersInner({
<View
key={prof.did}
style={[
a.absolute,
a.rounded_full,
{
borderWidth: AVI_BORDER,
borderColor: t.atoms.bg.backgroundColor,
width: SIZE + AVI_BORDER * 2,
height: SIZE + AVI_BORDER * 2,
left: i * a.gap_md.gap,
zIndex: AVI_BORDER - i,
marginLeft: i > 0 ? -8 : 0,
},
]}>
<UserAvatar
@@ -168,7 +165,6 @@ function KnownFollowersInner({
avatar={prof.avatar}
moderation={moderation.ui('avatar')}
type={prof.associated?.labeler ? 'labeler' : 'user'}
noBorder
/>
</View>
))}
-50
View File
@@ -1,50 +0,0 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES} from '#/locale/languages'
import * as Select from '#/components/Select'
export function LanguageSelect({
value,
onChange,
items = APP_LANGUAGES.map(l => ({
label: l.name,
value: l.code2,
})),
}: {
value?: string
onChange: (value: string) => void
items?: {label: string; value: string}[]
}) {
const {_} = useLingui()
const handleOnChange = React.useCallback(
(value: string) => {
if (!value) return
onChange(sanitizeAppLanguageSetting(value))
},
[onChange],
)
return (
<Select.Root
value={value ? sanitizeAppLanguageSetting(value) : undefined}
onValueChange={handleOnChange}>
<Select.Trigger label={_(msg`Select language`)}>
<Select.ValueText placeholder={_(msg`Select language`)} />
<Select.Icon />
</Select.Trigger>
<Select.Content
renderItem={({label, value}) => (
<Select.Item value={value} label={label}>
<Select.ItemIndicator />
<Select.ItemText>{label}</Select.ItemText>
</Select.Item>
)}
items={items}
/>
</Select.Root>
)
}
+22 -57
View File
@@ -9,7 +9,7 @@ import {
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
import {type AllNavigatorParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {
convertBskyAppUrlIfNeeded,
@@ -24,7 +24,6 @@ import {Button, type ButtonProps} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text, type TextProps} from '#/components/Typography'
import {router} from '#/routes'
import {useGlobalDialogsControlContext} from './dialogs/Context'
/**
* Only available within a `Link`, since that inherits from `Button`.
@@ -99,10 +98,10 @@ export function useLink({
return typeof to === 'string'
? convertBskyAppUrlIfNeeded(sanitizeUrl(to))
: to.screen
? router.matchName(to.screen)?.build(to.params)
: to.href
? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
: undefined
? router.matchName(to.screen)?.build(to.params)
: to.href
? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
: undefined
}, [to])
if (!href) {
@@ -112,8 +111,7 @@ export function useLink({
}
const isExternal = isExternalUrl(href)
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const {openModal, closeModal} = useModalControls()
const openLink = useOpenLink()
const onPress = React.useCallback(
@@ -134,9 +132,10 @@ export function useLink({
}
if (requiresWarning) {
linkWarningDialogControl.open({
displayText,
href,
openModal({
name: 'link-warning',
text: displayText,
href: href,
})
} else {
if (isExternal) {
@@ -155,44 +154,15 @@ export function useLink({
} else {
closeModal() // close any active modals
const [screen, params] = router.matchPath(href) as [
screen: keyof AllNavigatorParams,
params?: RouteParams,
]
// does not apply to web's flat navigator
if (isNative && screen !== 'NotFound') {
const state = navigation.getState()
// if screen is not in the current navigator, it means it's
// most likely a tab screen
if (!state.routeNames.includes(screen)) {
const parent = navigation.getParent()
if (
parent &&
parent.getState().routeNames.includes(`${screen}Tab`)
) {
// yep, it's a tab screen. i.e. SearchTab
// thus we need to navigate to the child screen
// via the parent navigator
// see https://reactnavigation.org/docs/upgrading-from-6.x/#changes-to-the-navigate-action
// TODO: can we support the other kinds of actions? push/replace -sfn
// @ts-expect-error include does not narrow the type unfortunately
parent.navigate(`${screen}Tab`, {screen, params})
return
} else {
// will probably fail, but let's try anyway
}
}
}
if (action === 'push') {
navigation.dispatch(StackActions.push(screen, params))
navigation.dispatch(StackActions.push(...router.matchPath(href)))
} else if (action === 'replace') {
navigation.dispatch(StackActions.replace(screen, params))
navigation.dispatch(
StackActions.replace(...router.matchPath(href)),
)
} else if (action === 'navigate') {
// @ts-expect-error not typed
navigation.navigate(screen, params, {pop: true})
// @ts-ignore
navigation.navigate(...router.matchPath(href))
} else {
throw Error('Unsupported navigator action.')
}
@@ -206,13 +176,13 @@ export function useLink({
displayText,
isExternal,
href,
openModal,
openLink,
closeModal,
action,
navigation,
overridePresentation,
shouldProxy,
linkWarningDialogControl,
],
)
@@ -225,21 +195,16 @@ export function useLink({
)
if (requiresWarning) {
linkWarningDialogControl.open({
displayText,
href,
openModal({
name: 'link-warning',
text: displayText,
href: href,
share: true,
})
} else {
shareUrl(href)
}
}, [
disableMismatchWarning,
displayText,
href,
isExternal,
linkWarningDialogControl,
])
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
const onLongPress = React.useCallback(
(e: GestureResponderEvent) => {
+5 -7
View File
@@ -1,10 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import {
type AppBskyGraphDefs,
AppBskyGraphDefs,
AtUri,
moderateUserList,
type ModerationUI,
ModerationUI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -22,10 +22,10 @@ import {
Outer,
SaveButton,
} from '#/components/FeedCard'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import * as bsky from '#/types/bsky'
/*
* This component is based on `FeedCard` and is tightly coupled with that
@@ -50,9 +50,7 @@ type Props = {
showPinButton?: boolean
}
export function Default(
props: Props & Omit<LinkProps, 'to' | 'label' | 'children'>,
) {
export function Default(props: Props) {
const {view, showPinButton} = props
const moderationOpts = useModerationOpts()
const moderation = moderationOpts
+21 -22
View File
@@ -1,9 +1,10 @@
import {useMemo} from 'react'
import React from 'react'
import {StyleSheet} from 'react-native'
import {moderateFeedGenerator} from '@atproto/api'
import {usePalette} from '#/lib/hooks/usePalette'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {atoms as a, useTheme} from '#/alf'
import * as FeedCard from '#/components/FeedCard'
import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
import {ContentHider} from '#/components/moderation/ContentHider'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
@@ -13,22 +14,13 @@ export function FeedEmbed({
}: CommonProps & {
embed: EmbedType<'feed'>
}) {
const t = useTheme()
const pal = usePalette('default')
return (
<FeedCard.Link
view={embed.view}
style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
<FeedCard.Outer>
<FeedCard.Header>
<FeedCard.Avatar src={embed.view.avatar} />
<FeedCard.TitleAndByline
title={embed.view.displayName}
creator={embed.view.creator}
/>
</FeedCard.Header>
<FeedCard.Likes count={embed.view.likeCount || 0} />
</FeedCard.Outer>
</FeedCard.Link>
<FeedSourceCard
feedUri={embed.view.uri}
style={[pal.view, pal.border, styles.customFeedOuter]}
showLikes
/>
)
}
@@ -38,16 +30,23 @@ export function ModeratedFeedEmbed({
embed: EmbedType<'feed'>
}) {
const moderationOpts = useModerationOpts()
const moderation = useMemo(() => {
const moderation = React.useMemo(() => {
return moderationOpts
? moderateFeedGenerator(embed.view, moderationOpts)
: undefined
}, [embed.view, moderationOpts])
return (
<ContentHider
modui={moderation?.ui('contentList')}
childContainerStyle={[a.pt_xs]}>
<ContentHider modui={moderation?.ui('contentList')}>
<FeedEmbed embed={embed} />
</ContentHider>
)
}
const styles = StyleSheet.create({
customFeedOuter: {
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
},
})
+3 -3
View File
@@ -77,9 +77,9 @@ export function ImageEmbed({
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
+8 -9
View File
@@ -1,4 +1,5 @@
import {useMemo} from 'react'
import React from 'react'
import {View} from 'react-native'
import {moderateUserList} from '@atproto/api'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -15,10 +16,10 @@ export function ListEmbed({
}) {
const t = useTheme()
return (
<ListCard.Default
view={embed.view}
style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}
/>
<View
style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
<ListCard.Default view={embed.view} />
</View>
)
}
@@ -28,15 +29,13 @@ export function ModeratedListEmbed({
embed: EmbedType<'list'>
}) {
const moderationOpts = useModerationOpts()
const moderation = useMemo(() => {
const moderation = React.useMemo(() => {
return moderationOpts
? moderateUserList(embed.view, moderationOpts)
: undefined
}, [embed.view, moderationOpts])
return (
<ContentHider
modui={moderation?.ui('contentList')}
childContainerStyle={[a.pt_xs]}>
<ContentHider modui={moderation?.ui('contentList')}>
<ListEmbed embed={embed} />
</ContentHider>
)
@@ -317,8 +317,8 @@ export function Controls({
!focused
? msg`Unmute video`
: playing
? msg`Pause video`
: msg`Play video`,
? msg`Pause video`
: msg`Play video`,
)}
accessibilityHint=""
style={[
@@ -1,7 +1,6 @@
import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'
import {isSafari} from '#/lib/browser'
import {logger} from '#/logger'
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
@@ -80,12 +79,7 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
await ref.current.play()
} catch (e: any) {
if (
!e.message?.includes(
`The request is not allowed by the user agent`,
) &&
!e.message?.includes(
`The play() request was interrupted by a call to pause()`,
)
!e.message?.includes(`The request is not allowed by the user agent`)
) {
throw e
}
@@ -182,15 +176,8 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
} else {
const promise = ref.current.play()
if (promise !== undefined) {
promise.catch((err: any) => {
if (
// ignore this common error. it's fine
!err.message?.includes(
`The play() request was interrupted by a call to pause()`,
)
) {
logger.error('Error playing video:', {message: err})
}
promise.catch(err => {
console.error('Error playing video:', err)
})
}
}
+55 -51
View File
@@ -268,60 +268,64 @@ export function QuoteEmbed({
const [hover, setHover] = React.useState(false)
return (
<View
style={[a.mt_sm]}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}>
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
activeStyle={[a.p_md, a.pt_sm]}
style={[
a.rounded_md,
a.p_md,
a.mt_sm,
a.border,
t.atoms.border_contrast_low,
style,
]}
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && <SubtleWebHover hover={hover} style={[a.rounded_md]} />}
<Link
style={[!active && a.p_md]}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
postHref={itemHref}
timestamp={quote.indexedAt}
/>
</View>
{moderation ? (
<PostAlerts
modui={moderation.ui('contentView')}
style={[a.py_xs]}
/>
) : null}
{richText ? (
<RichText
value={richText}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</Link>
</>
)}
<SubtleWebHover hover={hover} />
<Link
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
postHref={itemHref}
timestamp={quote.indexedAt}
/>
</View>
{moderation ? (
<PostAlerts
modui={moderation.ui('contentView')}
style={[a.py_xs]}
/>
) : null}
{richText ? (
<RichText
value={richText}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</Link>
</ContentHider>
</View>
)
@@ -600,8 +600,8 @@ let PostMenuItems = ({
isDetachPending
? Loader
: quoteEmbed.isDetached
? Eye
: EyeSlash
? Eye
: EyeSlash
}
position="right"
/>
+2 -13
View File
@@ -40,17 +40,6 @@ let RepostButton = ({
const requireAuth = useRequireAuth()
const dialogControl = Dialog.useDialogControl()
const onPress = () => requireAuth(() => dialogControl.open())
const onLongPress = () =>
requireAuth(() => {
if (embeddingDisabled) {
dialogControl.open()
} else {
onQuote()
}
})
return (
<>
<PostControlButton
@@ -58,8 +47,8 @@ let RepostButton = ({
active={isReposted}
activeColor={t.palette.positive_600}
big={big}
onPress={onPress}
onLongPress={onLongPress}
onPress={() => requireAuth(() => dialogControl.open())}
onLongPress={() => requireAuth(() => onQuote())}
label={
isReposted
? _(
@@ -62,17 +62,11 @@ export const RepostButton = ({
</Menu.Trigger>
<Menu.Outer style={{minWidth: 170}}>
<Menu.Item
label={
isReposted
? _(msg`Undo repost`)
: _(msg({message: `Repost`, context: `action`}))
}
label={isReposted ? _(msg`Undo repost`) : _(msg`Repost`)}
testID="repostDropdownRepostBtn"
onPress={onRepost}>
<Menu.ItemText>
{isReposted
? _(msg`Undo repost`)
: _(msg({message: `Repost`, context: `action`}))}
{isReposted ? _(msg`Undo repost`) : _(msg`Repost`)}
</Menu.ItemText>
<Menu.ItemIcon icon={Repost} position="right" />
</Menu.Item>
@@ -11,7 +11,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
@@ -37,7 +36,6 @@ let ShareMenuItems = ({
const navigation = useNavigation<NavigationProp>()
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
const {isAgeRestricted} = useAgeAssurance()
const postUri = post.uri
const postAuthor = useProfileShadow(post.author)
@@ -91,7 +89,7 @@ let ShareMenuItems = ({
return (
<>
<Menu.Outer>
{hasSession && !isAgeRestricted && (
{hasSession && (
<Menu.Group>
<Menu.ContainerItem>
<RecentChats postUri={postUri} />
@@ -11,7 +11,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import {useBreakpoints} from '#/alf'
@@ -39,7 +38,6 @@ let ShareMenuItems = ({
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
const {isAgeRestricted} = useAgeAssurance()
const postUri = post.uri
const postCid = post.cid
@@ -98,7 +96,7 @@ let ShareMenuItems = ({
<Menu.Outer>
{!hideInPWI && copyLinkItem}
{hasSession && !isAgeRestricted && (
{hasSession && (
<Menu.Item
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
+1 -8
View File
@@ -185,14 +185,7 @@ let PostControls = ({
}
return (
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
!big && a.pt_2xs,
style,
]}>
<View style={[a.flex_row, a.justify_between, a.align_center, style]}>
<View
style={[
big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
+7 -78
View File
@@ -1,4 +1,4 @@
import {useMemo} from 'react'
import React from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
moderateProfile,
@@ -11,8 +11,6 @@ import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig'
import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -20,7 +18,7 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, platform, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {
Button,
ButtonIcon,
@@ -185,77 +183,14 @@ export function AvatarPlaceholder() {
export function NameAndHandle({
profile,
moderationOpts,
inline = false,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
inline?: boolean
}) {
if (inline) {
return (
<InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} />
)
} else {
return (
<View style={[a.flex_1]}>
<Name profile={profile} moderationOpts={moderationOpts} />
<Handle profile={profile} />
</View>
)
}
}
function InlineNameAndHandle({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const verification = useSimpleVerificationState({profile})
const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const handle = sanitizeHandle(profile.handle, '@')
return (
<View style={[a.flex_row, a.align_end, a.flex_shrink]}>
<Text
emoji
style={[
a.font_bold,
a.leading_tight,
a.flex_shrink_0,
{maxWidth: '70%'},
]}
numberOfLines={1}>
{forceLTR(name)}
</Text>
{verification.showBadge && (
<View
style={[
a.pl_2xs,
a.self_center,
{marginTop: platform({default: 0, android: -1})},
]}>
<VerificationCheck
width={platform({android: 13, default: 12})}
verifier={verification.role === 'verifier'}
/>
</View>
)}
<Text
emoji
style={[
a.leading_tight,
t.atoms.text_contrast_medium,
{flexShrink: 10},
]}
numberOfLines={1}>
{NON_BREAKING_SPACE + handle}
</Text>
<View style={[a.flex_1]}>
<Name profile={profile} moderationOpts={moderationOpts} />
<Handle profile={profile} />
</View>
)
}
@@ -277,13 +212,7 @@ export function Name({
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.text_md,
a.font_bold,
a.leading_snug,
a.self_start,
a.flex_shrink,
]}
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{name}
</Text>
@@ -351,7 +280,7 @@ export function Description({
numberOfLines?: number
}) {
const profile = useProfileShadow(profileUnshadowed)
const rt = useMemo(() => {
const rt = React.useMemo(() => {
if (!('description' in profile)) return
const rt = new RichTextApi({text: profile.description || ''})
rt.detectFacetsWithoutResolution()
@@ -74,7 +74,7 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) {
return props.children
} else {
return (
<View onPointerMove={onPointerMove} style={[a.flex_shrink, props.style]}>
<View onPointerMove={onPointerMove} style={[a.flex_shrink]}>
<ProfileHoverCardInner {...props} />
</View>
)
+2 -4
View File
@@ -1,9 +1,7 @@
import type React from 'react'
import {type ViewStyleProp} from '#/alf'
export type ProfileHoverCardProps = ViewStyleProp & {
children: React.ReactNode
export type ProfileHoverCardProps = {
children: React.ReactElement
did: string
disable?: boolean
}
-45
View File
@@ -1,45 +0,0 @@
import {View} from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette'
import {atoms as a, useBreakpoints} from '#/alf'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import {TimesLarge_Stroke2_Corner0_Rounded} from './icons/Times'
export function SearchError({
title,
children,
}: {
title?: string
children?: React.ReactNode
}) {
const {gtMobile} = useBreakpoints()
const pal = usePalette('default')
return (
<Layout.Content>
<View
style={[
a.align_center,
a.gap_4xl,
a.px_xl,
{
paddingVertical: 150,
},
]}>
<TimesLarge_Stroke2_Corner0_Rounded width={32} fill={pal.colors.icon} />
<View
style={[
a.align_center,
{maxWidth: gtMobile ? 394 : 294},
gtMobile ? a.gap_md : a.gap_sm,
]}>
<Text style={[a.font_bold, a.text_lg, a.text_center, a.leading_snug]}>
{title}
</Text>
{children}
</View>
</View>
</Layout.Content>
)
}
+2 -3
View File
@@ -111,8 +111,8 @@ export function Trigger({children, label}: TriggerProps) {
borderColor: focused
? t.palette.primary_500
: hovered
? t.palette.contrast_100
: t.palette.contrast_25,
? t.palette.contrast_100
: t.palette.contrast_25,
},
])}>
{children}
@@ -244,7 +244,6 @@ export function Item({ref, value, style, children}: ItemProps) {
onFocus={onFocus}
onBlur={onBlur}
style={flatten([
t.atoms.text,
a.relative,
a.flex,
{minHeight: 25, paddingLeft: 30, paddingRight: 35},
@@ -156,6 +156,7 @@ export function Link({
return (
<BaseLink
action="push"
to={`/starter-pack/${handleOrDid}/${rkey}`}
label={_(msg`Navigate to ${record.name}`)}
onPress={() => {
-6
View File
@@ -1,6 +0,0 @@
import {atoms as a} from '#/alf'
export const BUBBLE_MAX_WIDTH = 240
export const ARROW_SIZE = 12
export const ARROW_HALF_SIZE = ARROW_SIZE / 2
export const MIN_EDGE_SPACE = a.px_lg.paddingLeft
-409
View File
@@ -1,409 +0,0 @@
import {
Children,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {atoms as a, select, useTheme} from '#/alf'
import {useOnGesture} from '#/components/hooks/useOnGesture'
import {Portal} from '#/components/Portal'
import {
ARROW_HALF_SIZE,
ARROW_SIZE,
BUBBLE_MAX_WIDTH,
MIN_EDGE_SPACE,
} from '#/components/Tooltip/const'
import {Text} from '#/components/Typography'
/**
* These are native specific values, not shared with web
*/
const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target
const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip
type TooltipContextType = {
position: 'top' | 'bottom'
visible: boolean
onVisibleChange: (visible: boolean) => void
}
type TargetMeasurements = {
x: number
y: number
width: number
height: number
}
type TargetContextType = {
targetMeasurements: TargetMeasurements | undefined
setTargetMeasurements: (measurements: TargetMeasurements) => void
shouldMeasure: boolean
}
const TooltipContext = createContext<TooltipContextType>({
position: 'bottom',
visible: false,
onVisibleChange: () => {},
})
const TargetContext = createContext<TargetContextType>({
targetMeasurements: undefined,
setTargetMeasurements: () => {},
shouldMeasure: false,
})
export function Outer({
children,
position = 'bottom',
visible: requestVisible,
onVisibleChange,
}: {
children: React.ReactNode
position?: 'top' | 'bottom'
visible: boolean
onVisibleChange: (visible: boolean) => void
}) {
/**
* Lagging state to track the externally-controlled visibility of the
* tooltip, which needs to wait for the target to be measured before
* actually being shown.
*/
const [visible, setVisible] = useState<boolean>(false)
const [targetMeasurements, setTargetMeasurements] = useState<
| {
x: number
y: number
width: number
height: number
}
| undefined
>(undefined)
if (requestVisible && !visible && targetMeasurements) {
setVisible(true)
} else if (!requestVisible && visible) {
setVisible(false)
setTargetMeasurements(undefined)
}
const ctx = useMemo(
() => ({position, visible, onVisibleChange}),
[position, visible, onVisibleChange],
)
const targetCtx = useMemo(
() => ({
targetMeasurements,
setTargetMeasurements,
shouldMeasure: requestVisible,
}),
[requestVisible, targetMeasurements, setTargetMeasurements],
)
return (
<TooltipContext.Provider value={ctx}>
<TargetContext.Provider value={targetCtx}>
{children}
</TargetContext.Provider>
</TooltipContext.Provider>
)
}
export function Target({children}: {children: React.ReactNode}) {
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
const targetRef = useRef<View>(null)
useEffect(() => {
if (!shouldMeasure) return
/*
* Once opened, measure the dimensions and position of the target
*/
targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
if (pageX !== undefined && pageY !== undefined && width && height) {
setTargetMeasurements({x: pageX, y: pageY, width, height})
}
})
}, [shouldMeasure, setTargetMeasurements])
return (
<View collapsable={false} ref={targetRef}>
{children}
</View>
)
}
export function Content({
children,
label,
}: {
children: React.ReactNode
label: string
}) {
const {position, visible, onVisibleChange} = useContext(TooltipContext)
const {targetMeasurements} = useContext(TargetContext)
const requestClose = useCallback(() => {
onVisibleChange(false)
}, [onVisibleChange])
if (!visible || !targetMeasurements) return null
return (
<Portal>
<Bubble
label={label}
position={position}
/*
* Gotta pass these in here. Inside the Bubble, we're Potal-ed outside
* the context providers.
*/
targetMeasurements={targetMeasurements}
requestClose={requestClose}>
{children}
</Bubble>
</Portal>
)
}
function Bubble({
children,
label,
position,
requestClose,
targetMeasurements,
}: {
children: React.ReactNode
label: string
position: TooltipContextType['position']
requestClose: () => void
targetMeasurements: Exclude<
TargetContextType['targetMeasurements'],
undefined
>
}) {
const t = useTheme()
const insets = useSafeAreaInsets()
const dimensions = useWindowDimensions()
const [bubbleMeasurements, setBubbleMeasurements] = useState<
| {
width: number
height: number
}
| undefined
>(undefined)
const coords = useMemo(() => {
if (!bubbleMeasurements)
return {
top: 0,
bottom: 0,
left: 0,
right: 0,
tipTop: 0,
tipLeft: 0,
}
const {width: ww, height: wh} = dimensions
const maxTop = insets.top
const maxBottom = wh - insets.bottom
const {width: cw, height: ch} = bubbleMeasurements
const minLeft = MIN_EDGE_SPACE
const maxLeft = ww - minLeft
let computedPosition: 'top' | 'bottom' = position
let top = targetMeasurements.y + targetMeasurements.height
let left = Math.max(
minLeft,
targetMeasurements.x + targetMeasurements.width / 2 - cw / 2,
)
const tipTranslate = ARROW_HALF_SIZE * -1
let tipTop = tipTranslate
if (left + cw > maxLeft) {
left -= left + cw - maxLeft
}
let tipLeft =
targetMeasurements.x -
left +
targetMeasurements.width / 2 -
ARROW_HALF_SIZE
let bottom = top + ch
function positionTop() {
top = top - ch - targetMeasurements.height
bottom = top + ch
tipTop = tipTop + ch
computedPosition = 'top'
}
function positionBottom() {
top = targetMeasurements.y + targetMeasurements.height
bottom = top + ch
tipTop = tipTranslate
computedPosition = 'bottom'
}
if (position === 'top') {
positionTop()
if (top < maxTop) {
positionBottom()
}
} else {
if (bottom > maxBottom) {
positionTop()
}
}
if (computedPosition === 'bottom') {
top += ARROW_VISUAL_OFFSET
bottom += ARROW_VISUAL_OFFSET
} else {
top -= ARROW_VISUAL_OFFSET
bottom -= ARROW_VISUAL_OFFSET
}
return {
computedPosition,
top,
bottom,
left,
right: left + cw,
tipTop,
tipLeft,
}
}, [position, targetMeasurements, bubbleMeasurements, insets, dimensions])
const requestCloseWrapped = useCallback(() => {
setBubbleMeasurements(undefined)
requestClose()
}, [requestClose])
useOnGesture(
useCallback(
e => {
const {x, y} = e
const isInside =
x > coords.left &&
x < coords.right &&
y > coords.top &&
y < coords.bottom
if (!isInside) {
requestCloseWrapped()
}
},
[coords, requestCloseWrapped],
),
)
return (
<View
accessible
role="alert"
accessibilityHint=""
accessibilityLabel={label}
// android
importantForAccessibility="yes"
// ios
accessibilityViewIsModal
style={[
a.absolute,
a.align_start,
{
width: BUBBLE_MAX_WIDTH,
opacity: bubbleMeasurements ? 1 : 0,
top: coords.top,
left: coords.left,
},
]}>
<Animated.View
entering={ZoomIn.easing(Easing.out(Easing.exp))}
style={{transformOrigin: oppposite(position)}}>
<View
style={[
a.absolute,
a.top_0,
a.z_10,
t.atoms.bg,
select(t.name, {
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
{
borderTopLeftRadius: a.rounded_2xs.borderRadius,
borderBottomRightRadius: a.rounded_2xs.borderRadius,
width: ARROW_SIZE,
height: ARROW_SIZE,
transform: [{rotate: '45deg'}],
top: coords.tipTop,
left: coords.tipLeft,
},
]}
/>
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
select(t.name, {
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
t.atoms.shadow_md,
{
shadowOpacity: 0.2,
shadowOffset: {
width: 0,
height:
BUBBLE_SHADOW_OFFSET *
(coords.computedPosition === 'bottom' ? -1 : 1),
},
},
]}
onLayout={e => {
setBubbleMeasurements({
width: e.nativeEvent.layout.width,
height: e.nativeEvent.layout.height,
})
}}>
{children}
</View>
</Animated.View>
</View>
)
}
function oppposite(position: 'top' | 'bottom') {
switch (position) {
case 'top':
return 'center bottom'
case 'bottom':
return 'center top'
default:
return 'center'
}
}
export function TextBubble({children}: {children: React.ReactNode}) {
const c = Children.toArray(children)
return (
<Content label={c.join(' ')}>
<View style={[a.gap_xs]}>
{c.map((child, i) => (
<Text key={i} style={[a.text_sm, a.leading_snug]}>
{child}
</Text>
))}
</View>
</Content>
)
}
-118
View File
@@ -1,118 +0,0 @@
import {Children, createContext, useContext, useMemo} from 'react'
import {View} from 'react-native'
import {Popover} from 'radix-ui'
import {atoms as a, flatten, select, useTheme} from '#/alf'
import {transparentifyColor} from '#/alf/util/colorGeneration'
import {
ARROW_SIZE,
BUBBLE_MAX_WIDTH,
MIN_EDGE_SPACE,
} from '#/components/Tooltip/const'
import {Text} from '#/components/Typography'
type TooltipContextType = {
position: 'top' | 'bottom'
onVisibleChange: (open: boolean) => void
}
const TooltipContext = createContext<TooltipContextType>({
position: 'bottom',
onVisibleChange: () => {},
})
export function Outer({
children,
position = 'bottom',
visible,
onVisibleChange,
}: {
children: React.ReactNode
position?: 'top' | 'bottom'
visible: boolean
onVisibleChange: (visible: boolean) => void
}) {
const ctx = useMemo(
() => ({position, onVisibleChange}),
[position, onVisibleChange],
)
return (
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
</Popover.Root>
)
}
export function Target({children}: {children: React.ReactNode}) {
return (
<Popover.Trigger asChild>
<View collapsable={false}>{children}</View>
</Popover.Trigger>
)
}
export function Content({
children,
label,
}: {
children: React.ReactNode
label: string
}) {
const t = useTheme()
const {position, onVisibleChange} = useContext(TooltipContext)
return (
<Popover.Portal>
<Popover.Content
className="radix-popover-content"
aria-label={label}
side={position}
sideOffset={4}
collisionPadding={MIN_EDGE_SPACE}
onInteractOutside={() => onVisibleChange(false)}
style={flatten([
a.rounded_sm,
select(t.name, {
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
{
minWidth: 'max-content',
boxShadow: select(t.name, {
light: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
dark: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
dim: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
}),
},
])}>
<Popover.Arrow
width={ARROW_SIZE}
height={ARROW_SIZE / 2}
fill={select(t.name, {
light: t.atoms.bg.backgroundColor,
dark: t.atoms.bg_contrast_100.backgroundColor,
dim: t.atoms.bg_contrast_100.backgroundColor,
})}
/>
<View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}>
{children}
</View>
</Popover.Content>
</Popover.Portal>
)
}
export function TextBubble({children}: {children: React.ReactNode}) {
const c = Children.toArray(children)
return (
<Content label={c.join(' ')}>
<View style={[a.gap_xs]}>
{c.map((child, i) => (
<Text key={i} style={[a.text_sm, a.leading_snug]}>
{child}
</Text>
))}
</View>
</Content>
)
}
+33 -47
View File
@@ -390,7 +390,6 @@ export function CompactVideoPostCard({
if (!AppBskyEmbedVideo.isView(embed)) return null
const likeCount = post?.likeCount ?? 0
const showLikeCount = false
const {thumbnail} = embed
const black = getBlackColor(t)
@@ -411,7 +410,6 @@ export function CompactVideoPostCard({
onPressOut={onPressOut}
style={[
a.flex_col,
t.atoms.shadow_sm,
{
alignItems: undefined,
justifyContent: undefined,
@@ -422,10 +420,8 @@ export function CompactVideoPostCard({
<View
style={[
a.justify_center,
a.rounded_lg,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{
backgroundColor: black,
aspectRatio: 9 / 16,
@@ -446,8 +442,6 @@ export function CompactVideoPostCard({
a.inset_0,
a.justify_center,
a.align_center,
a.border,
t.atoms.border_contrast_low,
{
backgroundColor: 'black',
opacity: 0.2,
@@ -467,10 +461,8 @@ export function CompactVideoPostCard({
<View
style={[
a.justify_center,
a.rounded_lg,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{
backgroundColor: black,
aspectRatio: 9 / 16,
@@ -483,51 +475,47 @@ export function CompactVideoPostCard({
/>
<MediaInsetBorder />
<View style={[a.absolute, a.inset_0, t.atoms.shadow_sm]}>
<View style={[a.absolute, a.inset_0]}>
<View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
<View
style={[a.relative, a.rounded_full, {width: 24, height: 24}]}>
style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
<UserAvatar
type="user"
size={24}
size={20}
avatar={post.author.avatar}
/>
<MediaInsetBorder />
</View>
</View>
<View
style={[
a.absolute,
a.inset_0,
a.pt_2xl,
{
top: 'auto',
},
]}>
<LinearGradient
colors={[black, 'rgba(0, 0, 0, 0)']}
locations={[0.02, 1]}
start={{x: 0, y: 1}}
end={{x: 0, y: 0}}
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
/>
{showLikeCount && (
<View
style={[
a.absolute,
a.inset_0,
a.pt_2xl,
{
top: 'auto',
},
]}>
<LinearGradient
colors={[black, 'rgba(0, 0, 0, 0)']}
locations={[0.02, 1]}
start={{x: 0, y: 1}}
end={{x: 0, y: 0}}
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
/>
<View
style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
{likeCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Heart size="sm" fill="white" />
<Text
style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, likeCount)}
</Text>
</View>
)}
</View>
style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
{likeCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Heart size="sm" fill="white" />
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, likeCount)}
</Text>
</View>
)}
</View>
)}
</View>
</View>
</View>
</Hider.Content>
@@ -541,13 +529,11 @@ export function CompactVideoPostCardPlaceholder() {
const black = getBlackColor(t)
return (
<View style={[a.flex_1, t.atoms.shadow_sm]}>
<View style={[a.flex_1]}>
<View
style={[
a.rounded_lg,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{
backgroundColor: black,
aspectRatio: 9 / 16,
+15 -38
View File
@@ -1,15 +1,9 @@
import {Fragment, useMemo} from 'react'
import React from 'react'
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
import {
Keyboard,
Platform,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {
type AppBskyFeedDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
type AppBskyGraphDefs,
AppBskyGraphDefs,
AtUri,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
@@ -19,11 +13,11 @@ import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {isNative} from '#/platform/detection'
import {
type ThreadgateAllowUISetting,
ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {
@@ -61,7 +55,7 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
) && post.record.reply?.root
? post.record.reply.root.uri
: post.uri
const settings = useMemo(() => {
const settings = React.useMemo(() => {
return threadgateViewToAllowUISetting(post.threadgate)
}, [post.threadgate])
@@ -76,8 +70,8 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const description = anyoneCanReply
? _(msg`Everybody can reply`)
: noOneCanReply
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) {
@@ -160,9 +154,7 @@ function Icon({
width?: number
settings: ThreadgateAllowUISetting[]
}) {
const isEverybody =
settings.length === 0 ||
settings.every(setting => setting.type === 'everybody')
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group
return <IconComponent fill={color} width={width} />
@@ -180,13 +172,12 @@ function WhoCanReplyDialog({
embeddingDisabled: boolean
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Dialog: adjust who can interact with this post`)}
style={web({maxWidth: 400})}>
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
<Trans>Who can interact with this post?</Trans>
@@ -197,20 +188,6 @@ function WhoCanReplyDialog({
embeddingDisabled={embeddingDisabled}
/>
</View>
{isNative && (
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
size="small"
variant="solid"
color="secondary"
style={[a.mt_5xl]}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
@@ -249,10 +226,10 @@ function Rules({
<Trans>
Only{' '}
{settings.map((rule, i) => (
<Fragment key={`rule-${i}`}>
<React.Fragment key={`rule-${i}`}>
<Rule rule={rule} post={post} lists={post.threadgate!.lists} />
<Separator i={i} length={settings.length} />
</Fragment>
</React.Fragment>
))}{' '}
can reply.
</Trans>
@@ -1,89 +0,0 @@
import {useCallback} from 'react'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus'
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import * as Tooltip from '#/components/Tooltip'
import {Text} from '#/components/Typography'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
import type * as bsky from '#/types/bsky'
import {SubscribeProfileDialog} from './SubscribeProfileDialog'
export function SubscribeProfileButton({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const {_} = useLingui()
const requireEmailVerification = useRequireEmailVerification()
const subscribeDialogControl = useDialogControl()
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
useActivitySubscriptionsNudged()
const onDismissTooltip = () => {
setActivitySubscriptionsNudged(true)
}
const onPress = useCallback(() => {
subscribeDialogControl.open()
}, [subscribeDialogControl])
const name = createSanitizedDisplayName(profile, true)
const wrappedOnPress = requireEmailVerification(onPress, {
instructions: [
<Trans key="message">
Before you can get notifications for {name}'s posts, you must first
verify your email.
</Trans>,
],
})
const isSubscribed =
profile.viewer?.activitySubscription?.post ||
profile.viewer?.activitySubscription?.reply
const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon
return (
<>
<Tooltip.Outer
visible={!activitySubscriptionsNudged}
onVisibleChange={onDismissTooltip}
position="bottom">
<Tooltip.Target>
<Button
accessibilityRole="button"
testID="dmBtn"
size="small"
color="secondary"
variant="solid"
shape="round"
label={_(msg`Get notified when ${name} posts`)}
onPress={wrappedOnPress}>
<ButtonIcon icon={Icon} size="md" />
</Button>
</Tooltip.Target>
<Tooltip.TextBubble>
<Text>
<Trans>Get notified about new posts</Trans>
</Text>
</Tooltip.TextBubble>
</Tooltip.Outer>
<SubscribeProfileDialog
control={subscribeDialogControl}
profile={profile}
moderationOpts={moderationOpts}
/>
</>
)
}
@@ -1,309 +0,0 @@
import {useMemo, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyNotificationDefs,
type AppBskyNotificationListActivitySubscriptions,
type ModerationOpts,
type Un$Typed,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions'
import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {platform, useTheme, web} from '#/alf'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {
Button,
ButtonIcon,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export function SubscribeProfileDialog({
control,
profile,
moderationOpts,
includeProfile,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
includeProfile?: boolean
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner
profile={profile}
moderationOpts={moderationOpts}
includeProfile={includeProfile}
/>
</Dialog.Outer>
)
}
function DialogInner({
profile,
moderationOpts,
includeProfile,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
includeProfile?: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const agent = useAgent()
const control = Dialog.useDialogContext()
const queryClient = useQueryClient()
const initialState = parseActivitySubscription(
profile.viewer?.activitySubscription,
)
const [state, setState] = useState(initialState)
const values = useMemo(() => {
const {post, reply} = state
const res = []
if (post) res.push('post')
if (reply) res.push('reply')
return res
}, [state])
const onChange = (newValues: string[]) => {
setState(oldValues => {
// ensure you can't have reply without post
if (!oldValues.reply && newValues.includes('reply')) {
return {
post: true,
reply: true,
}
}
if (oldValues.post && !newValues.includes('post')) {
return {
post: false,
reply: false,
}
}
return {
post: newValues.includes('post'),
reply: newValues.includes('reply'),
}
})
}
const {
mutate: saveChanges,
isPending: isSaving,
error,
} = useMutation({
mutationFn: async (
activitySubscription: Un$Typed<AppBskyNotificationDefs.ActivitySubscription>,
) => {
await agent.app.bsky.notification.putActivitySubscription({
subject: profile.did,
activitySubscription,
})
},
onSuccess: (_data, activitySubscription) => {
control.close(() => {
updateProfileShadow(queryClient, profile.did, {
activitySubscription,
})
if (!activitySubscription.post && !activitySubscription.reply) {
logger.metric('activitySubscription:disable', {})
Toast.show(
_(
msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`,
),
'check',
)
// filter out the subscription
queryClient.setQueryData(
RQKEY_getActivitySubscriptions,
(
old?: InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema>,
) => {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
subscriptions: page.subscriptions.filter(
item => item.did !== profile.did,
),
})),
}
},
)
} else {
logger.metric('activitySubscription:enable', {
setting: activitySubscription.reply ? 'posts_and_replies' : 'posts',
})
if (!initialState.post && !initialState.reply) {
Toast.show(
_(
msg`You'll start receiving notifications for ${sanitizeHandle(profile.handle, '@')}!`,
),
'check',
)
} else {
Toast.show(_(msg`Changes saved`), 'check')
}
}
})
},
onError: err => {
logger.error('Could not save activity subscription', {message: err})
},
})
const buttonProps: Omit<ButtonProps, 'children'> = useMemo(() => {
const isDirty =
state.post !== initialState.post || state.reply !== initialState.reply
const hasAny = state.post || state.reply
if (isDirty) {
return {
label: _(msg`Save changes`),
color: hasAny ? 'primary' : 'negative',
onPress: () => saveChanges(state),
disabled: isSaving,
}
} else {
// on web, a disabled save button feels more natural than a massive close button
if (isWeb) {
return {
label: _(msg`Save changes`),
color: 'secondary',
disabled: true,
}
} else {
return {
label: _(msg`Cancel`),
color: 'secondary',
onPress: () => control.close(),
}
}
}
}, [state, initialState, control, _, isSaving, saveChanges])
const name = createSanitizedDisplayName(profile, false)
return (
<Dialog.ScrollableInner
style={web({maxWidth: 400})}
label={_(msg`Get notified of new posts from ${name}`)}>
<View style={[a.gap_lg]}>
<View style={[a.gap_xs]}>
<Text style={[a.font_heavy, a.text_2xl]}>
<Trans>Keep me posted</Trans>
</Text>
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
<Trans>Get notified of this accounts activity</Trans>
</Text>
</View>
{includeProfile && (
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
)}
<Toggle.Group
label={_(msg`Subscribe to account activity`)}
values={values}
onChange={onChange}>
<View style={[a.gap_sm]}>
<Toggle.Item
label={_(msg`Posts`)}
name="post"
style={[
a.flex_1,
a.py_xs,
platform({
native: [a.justify_between],
web: [a.flex_row_reverse, a.gap_sm],
}),
]}>
<Toggle.LabelText
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
<Trans>Posts</Trans>
</Toggle.LabelText>
<Toggle.Switch />
</Toggle.Item>
<Toggle.Item
label={_(msg`Replies`)}
name="reply"
style={[
a.flex_1,
a.py_xs,
platform({
native: [a.justify_between],
web: [a.flex_row_reverse, a.gap_sm],
}),
]}>
<Toggle.LabelText
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
<Trans>Replies</Trans>
</Toggle.LabelText>
<Toggle.Switch />
</Toggle.Item>
</View>
</Toggle.Group>
{error && (
<Admonition type="error">
<Trans>Could not save changes: {cleanError(error)}</Trans>
</Admonition>
)}
<Button {...buttonProps} size="large" variant="solid">
<ButtonText>{buttonProps.label}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} />}
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function parseActivitySubscription(
sub?: AppBskyNotificationDefs.ActivitySubscription,
): Un$Typed<AppBskyNotificationDefs.ActivitySubscription> {
if (!sub) return {post: false, reply: false}
const {post, reply} = sub
return {post, reply}
}
@@ -1,155 +0,0 @@
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {
AgeAssuranceInitDialog,
useDialogControl,
} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
const {isReady, isAgeRestricted, isDeclaredUnderage} = useAgeAssurance()
if (!isReady) return null
if (isDeclaredUnderage) return null
if (!isAgeRestricted) return null
return <Inner style={style} />
}
function Inner({style}: ViewStyleProp & {}) {
const t = useTheme()
const {_, i18n} = useLingui()
const control = useDialogControl()
const appealControl = Dialog.useDialogControl()
const getTimeAgo = useGetTimeAgo()
const {gtPhone} = useBreakpoints()
const copy = useAgeAssuranceCopy()
const {status, lastInitiatedAt} = useAgeAssurance()
const isBlocked = status === 'blocked'
const hasInitiated = !!lastInitiatedAt
const timeAgo = lastInitiatedAt
? getTimeAgo(lastInitiatedAt, new Date())
: null
const diff = lastInitiatedAt
? dateDiff(lastInitiatedAt, new Date(), 'down')
: null
return (
<>
<AgeAssuranceInitDialog control={control} />
<AgeAssuranceAppealDialog control={appealControl} />
<View style={style}>
<View
style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}>
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_lg,
a.pb_md,
a.z_10,
]}>
<View style={[a.align_start]}>
<AgeAssuranceBadge />
</View>
</View>
<View style={[a.pb_md]}>
<Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text>
</View>
{isBlocked ? (
<Admonition type="warning">
<Trans>
You are currently unable to access Bluesky's Age Assurance flow.
Please{' '}
<InlineLinkText
label={_(msg`Contact our moderation team`)}
{...createStaticClick(() => {
appealControl.open()
logger.metric('ageAssurance:appealDialogOpen', {})
})}>
contact our moderation team
</InlineLinkText>{' '}
if you believe this is an error.
</Trans>
</Admonition>
) : (
<>
<Divider />
<View
style={[
a.pt_md,
gtPhone
? [
a.flex_row_reverse,
a.gap_xl,
a.justify_between,
a.align_center,
]
: [a.gap_md],
]}>
<Button
label={_(msg`Verify now`)}
size="small"
variant="solid"
color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => {
control.open()
logger.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
<ButtonText>
{hasInitiated ? (
<Trans>Verify again</Trans>
) : (
<Trans>Verify now</Trans>
)}
</ButtonText>
</Button>
{lastInitiatedAt && timeAgo && diff ? (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
title={i18n.date(lastInitiatedAt, {
dateStyle: 'medium',
timeStyle: 'medium',
})}>
{diff.value === 0 ? (
<Trans>Last initiated just now</Trans>
) : (
<Trans>Last initiated {timeAgo} ago</Trans>
)}
</Text>
) : (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Trans>Age assurance only takes a few minutes</Trans>
</Text>
)}
</View>
</>
)}
</View>
</View>
</>
)
}
@@ -1,104 +0,0 @@
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import type * as Dialog from '#/components/Dialog'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function AgeAssuranceAdmonition({
children,
style,
}: ViewStyleProp & {children: React.ReactNode}) {
const control = useDialogControl()
const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
if (!isReady) return null
if (isDeclaredUnderage) return null
if (!isAgeRestricted) return null
return (
<Inner style={style} control={control}>
{children}
</Inner>
)
}
function Inner({
children,
style,
}: ViewStyleProp & {
children: React.ReactNode
control: Dialog.DialogControlProps
}) {
const t = useTheme()
const {_} = useLingui()
return (
<>
<View style={style}>
<View
style={[
a.p_md,
a.rounded_md,
a.border,
a.flex_row,
a.align_start,
a.gap_sm,
{
backgroundColor: select(t.name, {
light: t.palette.primary_25,
dark: t.palette.primary_25,
dim: t.palette.primary_25,
}),
borderColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 32,
height: 32,
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="md" />
</View>
<View style={[a.flex_1, a.gap_xs, a.pr_2xl]}>
<Text style={[a.text_sm, a.leading_snug]}>{children}</Text>
<Text style={[a.text_sm, a.leading_snug, a.font_bold]}>
<Trans>
Learn more in your{' '}
<InlineLinkText
label={_(msg`Go to account settings`)}
to={'/settings/account'}
style={[a.text_sm, a.leading_snug, a.font_bold]}
onPress={() => {
logger.metric('ageAssurance:navigateToSettings', {})
}}>
account settings.
</InlineLinkText>
</Trans>
</Text>
</View>
</View>
</View>
</>
)
}
@@ -1,142 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {BSKY_LABELER_DID, ComAtprotoModerationDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {logger} from '#/state/ageAssurance/util'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, web} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function AgeAssuranceAppealDialog({
control,
}: {
control: Dialog.DialogControlProps
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Contact our moderation team`)}
style={[web({maxWidth: 400})]}>
<Inner control={control} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Inner({control}: {control: Dialog.DialogControlProps}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const {gtPhone} = useBreakpoints()
const agent = useAgent()
const [details, setDetails] = React.useState('')
const isInvalid = details.length > 1000
const {mutate, isPending} = useMutation({
mutationFn: async () => {
logger.metric('ageAssurance:appealDialogSubmit', {})
await agent.createModerationReport(
{
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: currentAccount?.did,
},
reason: `AGE_ASSURANCE_INQUIRY: ` + details,
},
{
encoding: 'application/json',
headers: {
'atproto-proxy': `${BSKY_LABELER_DID}#atproto_labeler`,
},
},
)
},
onError: err => {
logger.error('AgeAssuranceAppealDialog failed', {safeMessage: err})
Toast.show(
_(msg`Age assurance inquiry failed to send, please try again.`),
'xmark',
)
},
onSuccess: () => {
control.close()
Toast.show(
_(
msg({
message: 'Age assurance inquiry was submitted',
context: 'toast',
}),
),
)
},
})
return (
<View>
<View style={[a.align_start]}>
<AgeAssuranceBadge />
</View>
<Text style={[a.text_2xl, a.font_heavy, a.pt_md, a.leading_tight]}>
<Trans>Contact us</Trans>
</Text>
<Text style={[a.text_sm, a.pt_sm, a.leading_snug]}>
<Trans>
Please provide any additional details you feel moderators may need in
order to properly assess your Age Assurance status.
</Trans>
</Text>
<View style={[a.pt_md]}>
<Dialog.Input
multiline
isInvalid={isInvalid}
value={details}
onChangeText={details => {
setDetails(details)
}}
label={_(msg`Additional details (limit 1000 characters)`)}
numberOfLines={4}
onSubmitEditing={() => mutate()}
/>
<View style={[a.pt_md, a.gap_sm, gtPhone && [a.flex_row_reverse]]}>
<Button
label={_(msg`Submit`)}
size="small"
variant="solid"
color="primary"
onPress={() => mutate()}>
<ButtonText>
<Trans>Submit</Trans>
</ButtonText>
{isPending && <ButtonIcon icon={Loader} position="right" />}
</Button>
<Button
label={_(msg`Cancel`)}
size="small"
variant="solid"
color="secondary"
onPress={() => control.close()}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
</View>
)
}
@@ -1,46 +0,0 @@
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {atoms as a, select, useTheme} from '#/alf'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {Text} from '#/components/Typography'
export function AgeAssuranceBadge() {
const t = useTheme()
return (
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
a.px_sm,
a.py_xs,
a.pr_sm,
a.rounded_full,
{
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="sm" />
<Text
style={[
a.font_bold,
a.leading_snug,
{
color: select(t.name, {
light: t.palette.primary_800,
dark: t.palette.primary_800,
dim: t.palette.primary_800,
}),
},
]}>
<Trans>Age Assurance</Trans>
</Text>
</View>
)
}
@@ -1,141 +0,0 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, select, useTheme} from '#/alf'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button} from '#/components/Button'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function useInternalState() {
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
if (!isReady) return false
if (isDeclaredUnderage) return false
if (!isAgeRestricted) return false
if (lastInitiatedAt) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
}, [
isReady,
isDeclaredUnderage,
isAgeRestricted,
lastInitiatedAt,
hidden,
nux,
])
const close = () => {
save({
id: Nux.AgeAssuranceDismissibleFeedBanner,
completed: true,
data: undefined,
})
}
return {visible, close}
}
export function AgeAssuranceDismissibleFeedBanner() {
const t = useTheme()
const {_} = useLingui()
const {visible, close} = useInternalState()
const copy = useAgeAssuranceCopy()
if (!visible) return null
return (
<View
style={[
a.px_lg,
{
paddingVertical: 10,
backgroundColor: select(t.name, {
light: t.palette.primary_25,
dark: t.palette.primary_25,
dim: t.palette.primary_25,
}),
},
]}>
<Link
label={_(msg`Learn more about age assurance`)}
to="/settings/account"
onPress={() => {
close()
logger.metric('ageAssurance:navigateToSettings', {})
}}
style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 42,
height: 42,
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="lg" />
</View>
<View
style={[
a.flex_1,
{
paddingRight: 40,
},
]}>
<View style={{maxWidth: 400}}>
<Text style={[a.leading_snug]}>{copy.banner}</Text>
</View>
</View>
</Link>
<Button
label={_(msg`Don't show again`)}
size="small"
onPress={() => {
close()
logger.metric('ageAssurance:dismissFeedBanner', {})
}}
style={[
a.absolute,
a.justify_center,
a.align_center,
{
top: 0,
bottom: 0,
right: 0,
paddingRight: a.px_md.paddingLeft,
},
]}>
<X
width={20}
fill={select(t.name, {
light: t.palette.primary_600,
dark: t.palette.primary_600,
dim: t.palette.primary_600,
})}
/>
</Button>
</View>
)
}
@@ -1,61 +0,0 @@
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, type ViewStyleProp} from '#/alf'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
const {_} = useLingui()
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice)
const copy = useAgeAssuranceCopy()
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
if (!isReady) return null
if (isDeclaredUnderage) return null
if (!isAgeRestricted) return null
if (lastInitiatedAt) return null
if (hidden) return null
if (nux && nux.completed) return null
return (
<View style={style}>
<View>
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
<Button
label={_(msg`Don't show again`)}
size="tiny"
variant="solid"
color="secondary_inverted"
shape="round"
onPress={() => {
save({
id: Nux.AgeAssuranceDismissibleNotice,
completed: true,
data: undefined,
})
logger.metric('ageAssurance:dismissSettingsNotice', {})
}}
style={[
a.absolute,
{
top: 12,
right: 12,
},
]}>
<ButtonIcon icon={X} />
</Button>
</View>
</View>
)
}
@@ -1,382 +0,0 @@
import {useState} from 'react'
import {View} from 'react-native'
import {XRPCError} from '@atproto/xrpc'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {validate as validateEmail} from 'email-validator'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {
SupportCode,
useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTLDs} from '#/lib/hooks/useTLDs'
import {isEmailMaybeInvalid} from '#/lib/strings/email'
import {type AppLanguage} from '#/locale/languages'
import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {urls} from '#/components/ageAssurance/const'
import {KWS_SUPPORTED_LANGS} from '#/components/ageAssurance/const'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import * as TextField from '#/components/forms/TextField'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {LanguageSelect} from '#/components/LanguageSelect'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export {useDialogControl} from '#/components/Dialog/context'
export function AgeAssuranceInitDialog({
control,
}: {
control: Dialog.DialogControlProps
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(
msg`Begin the age assurance process by completing the fields below.`,
)}
style={[
web({
maxWidth: 400,
}),
]}>
<Inner />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Inner() {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const langPrefs = useLanguagePrefs()
const cleanError = useCleanError()
const {close} = Dialog.useDialogContext()
const {lastInitiatedAt} = useAgeAssuranceContext()
const getTimeAgo = useGetTimeAgo()
const tlds = useTLDs()
const createSupportLink = useCreateSupportLink()
const wasRecentlyInitiated =
lastInitiatedAt &&
new Date(lastInitiatedAt).getTime() > Date.now() - 5 * 60 * 1000 // 5 minutes
const [success, setSuccess] = useState(false)
const [email, setEmail] = useState(currentAccount?.email || '')
const [emailError, setEmailError] = useState<string>('')
const [languageError, setLanguageError] = useState(false)
const [disabled, setDisabled] = useState(false)
const [language, setLanguage] = useState<string | undefined>(
convertToKWSSupportedLanguage(langPrefs.appLanguage),
)
const [error, setError] = useState<React.ReactNode>(null)
const {mutateAsync: init, isPending} = useInitAgeAssurance()
const runEmailValidation = () => {
if (validateEmail(email)) {
setEmailError('')
setDisabled(false)
if (tlds && isEmailMaybeInvalid(email, tlds)) {
setEmailError(
_(
msg`Please double-check that you have entered your email address correctly.`,
),
)
return {status: 'maybe'}
}
return {status: 'valid'}
}
setEmailError(_(msg`Please enter a valid email address.`))
setDisabled(true)
return {status: 'invalid'}
}
const onSubmit = async () => {
setLanguageError(false)
logger.metric('ageAssurance:initDialogSubmit', {})
try {
const {status} = runEmailValidation()
if (status === 'invalid') return
if (!language) {
setLanguageError(true)
return
}
await init({
email,
language,
})
setSuccess(true)
} catch (e) {
let error: React.ReactNode = _(
msg`Something went wrong, please try again`,
)
if (e instanceof XRPCError) {
if (e.error === 'InvalidEmail') {
error = _(
msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`,
)
logger.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'})
} else if (e.error === 'DidTooLong') {
error = (
<>
<Trans>
We're having issues initializing the age assurance process for
your account. Please{' '}
<InlineLinkText
to={createSupportLink({code: SupportCode.AA_DID, email})}
label={_(msg`Contact support`)}>
contact support
</InlineLinkText>{' '}
for assistance.
</Trans>
</>
)
logger.metric('ageAssurance:initDialogError', {code: 'DidTooLong'})
} else {
logger.metric('ageAssurance:initDialogError', {code: 'other'})
}
} else {
const {clean, raw} = cleanError(e)
error = clean || raw || error
logger.metric('ageAssurance:initDialogError', {code: 'other'})
}
setError(error)
}
}
return (
<View>
<View style={[a.align_start]}>
<AgeAssuranceBadge />
<Text style={[a.text_xl, a.font_heavy, a.pt_xl, a.pb_md]}>
{success ? <Trans>Success!</Trans> : <Trans>Verify your age</Trans>}
</Text>
<View style={[a.pb_xl, a.gap_sm]}>
{success ? (
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
Please check your email inbox for further instructions. It may
take a minute or two to arrive.
</Trans>
</Text>
) : (
<>
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
We have partnered with{' '}
<InlineLinkText
overridePresentation
disableMismatchWarning
label={_(msg`KWS website`)}
to={urls.kwsHome}
style={[a.text_sm, a.leading_snug]}>
KWS
</InlineLinkText>{' '}
to verify that youre an adult. When you click "Begin" below,
KWS will check if you have previously verified your age using
this email address for other games/services powered by KWS
technology. If not, KWS will email you instructions for
verifying your age. When youre done, you'll be brought back
to continue using Bluesky.
</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>This should only take a few minutes.</Trans>
</Text>
</>
)}
</View>
{success ? (
<View style={[a.w_full]}>
<Button
label={_(msg`Close dialog`)}
size="large"
variant="solid"
color="secondary"
onPress={() => close()}>
<ButtonText>
<Trans>Close dialog</Trans>
</ButtonText>
</Button>
</View>
) : (
<>
<Divider />
<View style={[a.w_full, a.pt_xl, a.gap_lg, a.pb_lg]}>
{wasRecentlyInitiated && (
<Admonition type="warning">
<Trans>
You initiated this flow already,{' '}
{getTimeAgo(lastInitiatedAt, new Date(), {format: 'long'})}{' '}
ago. It may take up to 5 minutes for emails to reach your
inbox. Please consider waiting a few minutes before trying
again.
</Trans>
</Admonition>
)}
<View>
<TextField.LabelText>
<Trans>Your email</Trans>
</TextField.LabelText>
<TextField.Root isInvalid={!!emailError}>
<TextField.Input
label={_(msg`Your email`)}
placeholder={_(msg`Your email`)}
value={email}
onChangeText={setEmail}
onFocus={() => setEmailError('')}
onBlur={() => {
runEmailValidation()
}}
returnKeyType="done"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
onSubmitEditing={onSubmit}
/>
</TextField.Root>
{emailError ? (
<Admonition type="error" style={[a.mt_sm]}>
{emailError}
</Admonition>
) : (
<Admonition type="tip" style={[a.mt_sm]}>
<Trans>
Use your account email address, or another real email
address you control, in case KWS or Bluesky needs to
contact you.
</Trans>
</Admonition>
)}
</View>
<View>
<TextField.LabelText>
<Trans>Your preferred language</Trans>
</TextField.LabelText>
<LanguageSelect
value={language}
onChange={value => {
setLanguage(value)
setLanguageError(false)
}}
items={KWS_SUPPORTED_LANGS}
/>
{languageError && (
<Admonition type="error" style={[a.mt_sm]}>
<Trans>Please select a language</Trans>
</Admonition>
)}
</View>
{error && <Admonition type="error">{error}</Admonition>}
<Button
disabled={disabled}
label={_(msg`Begin age assurance process`)}
size="large"
variant="solid"
color="primary"
onPress={onSubmit}>
<ButtonText>
<Trans>Begin</Trans>
</ButtonText>
<ButtonIcon
icon={isPending ? Loader : Shield}
position="right"
/>
</Button>
</View>
<Text
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
By continuing, you agree to the{' '}
<InlineLinkText
overridePresentation
disableMismatchWarning
label={_(msg`KWS Terms of Use`)}
to={urls.kwsTermsOfUse}
style={[a.text_xs, a.leading_snug]}>
KWS Terms of Use
</InlineLinkText>{' '}
and acknowledge that KWS will store your verified status with
your hashed email address in accordance with the{' '}
<InlineLinkText
overridePresentation
disableMismatchWarning
label={_(msg`KWS Privacy Policy`)}
to={urls.kwsPrivacyPolicy}
style={[a.text_xs, a.leading_snug]}>
KWS Privacy Policy
</InlineLinkText>
. This means you wont need to verify again the next time you
use this email for other apps, games, and services powered by
KWS technology.
</Trans>
</Text>
</>
)}
</View>
</View>
)
}
// best-effort mapping of our languages to KWS supported languages
function convertToKWSSupportedLanguage(
appLanguage: string,
): string | undefined {
// `${Enum}` is how you get a type of string union of the enum values (???) -sfn
switch (appLanguage as `${AppLanguage}`) {
// only en is supported
case 'en-GB':
return 'en'
// pt-PT is pt (pt-BR is supported independently)
case 'pt-PT':
return 'pt'
// only chinese (simplified) is supported, map all chinese variants
case 'zh-Hans-CN':
case 'zh-Hant-HK':
case 'zh-Hant-TW':
return 'zh-Hans'
default:
// try and map directly - if undefined, they will have to pick from the dropdown
return KWS_SUPPORTED_LANGS.find(v => v.value === appLanguage)?.value
}
}
@@ -1,253 +0,0 @@
import {useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {retry} from '#/lib/async/retry'
import {wait} from '#/lib/async/wait'
import {isNative} from '#/platform/detection'
import {useAgeAssuranceAPIContext} from '#/state/ageAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useAgent} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export type AgeAssuranceRedirectDialogState = {
result: 'success' | 'unknown'
actorDid: string
}
/**
* Validate and parse the query parameters returned from the age assurance
* redirect. If not valid, returns `undefined` and the dialog will not open.
*/
export function parseAgeAssuranceRedirectDialogState(
state: {
result?: string
actorDid?: string
} = {},
): AgeAssuranceRedirectDialogState | undefined {
let result: AgeAssuranceRedirectDialogState['result'] = 'unknown'
const actorDid = state.actorDid
switch (state.result) {
case 'success':
result = 'success'
break
case 'unknown':
default:
result = 'unknown'
break
}
if (result && actorDid) {
return {
result,
actorDid,
}
}
}
export function useAgeAssuranceRedirectDialogControl() {
return useGlobalDialogsControlContext().ageAssuranceRedirectDialogControl
}
export function AgeAssuranceRedirectDialog() {
const {_} = useLingui()
const control = useAgeAssuranceRedirectDialogControl()
// TODO for testing
// Dialog.useAutoOpen(control.control, 3e3)
return (
<Dialog.Outer control={control.control} onClose={() => control.clear()}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Verifying your age assurance status`)}
style={[web({maxWidth: 400})]}>
<Inner optimisticState={control.value} />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const polling = useRef(false)
const unmounted = useRef(false)
const control = useAgeAssuranceRedirectDialogControl()
const [error, setError] = useState(false)
const [success, setSuccess] = useState(false)
const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext()
useEffect(() => {
if (polling.current) return
polling.current = true
logger.metric('ageAssurance:redirectDialogOpen', {})
wait(
3e3,
retry(
5,
() => true,
async () => {
if (!agent.session) return
if (unmounted.current) return
const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState()
if (data.status !== 'assured') {
throw new Error(
`Polling for age assurance state did not receive assured status`,
)
}
return data
},
1e3,
),
)
.then(async data => {
if (!data) return
if (!agent.session) return
if (unmounted.current) return
// success! update state
await refreshAgeAssuranceState()
setSuccess(true)
logger.metric('ageAssurance:redirectDialogSuccess', {})
})
.catch(() => {
if (unmounted.current) return
setError(true)
// try a refetch anyway
refreshAgeAssuranceState()
logger.metric('ageAssurance:redirectDialogFail', {})
})
return () => {
unmounted.current = true
}
}, [agent, control, refreshAgeAssuranceState])
if (success) {
return (
<>
<View style={[a.align_start, a.w_full]}>
<AgeAssuranceBadge />
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_sm,
a.pt_lg,
a.pb_md,
]}>
<SuccessIcon size="sm" fill={t.palette.positive_600} />
<Text style={[a.text_xl, a.font_heavy]}>
<Trans>Success</Trans>
</Text>
</View>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
We've confirmed your age assurance status. You can now close this
dialog.
</Trans>
</Text>
{isNative && (
<View style={[a.w_full, a.pt_lg]}>
<Button
label={_(msg`Close`)}
size="large"
variant="solid"
color="secondary"
onPress={() => control.control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
)}
</View>
<Dialog.Close />
</>
)
}
return (
<>
<View style={[a.align_start, a.w_full]}>
<AgeAssuranceBadge />
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_sm,
a.pt_lg,
a.pb_md,
]}>
{error && <ErrorIcon size="md" fill={t.palette.negative_500} />}
<Text style={[a.text_xl, a.font_heavy]}>
{error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
</Text>
{!error && <Loader size="md" />}
</View>
<Text style={[a.text_md, a.leading_snug]}>
{error ? (
<Trans>
We were unable to receive the verification due to a connection
issue. It may arrive later. If it does, your account will update
automatically.
</Trans>
) : (
<Trans>
We're confirming your age assurance status with our servers. This
should only take a few seconds.
</Trans>
)}
</Text>
{error && isNative && (
<View style={[a.w_full, a.pt_lg]}>
<Button
label={_(msg`Close`)}
size="large"
variant="solid"
color="secondary"
onPress={() => control.control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
)}
</View>
{error && <Dialog.Close />}
</>
)
}
@@ -1,95 +0,0 @@
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function AgeRestrictedScreen({
children,
screenTitle,
infoText,
}: {
children: React.ReactNode
screenTitle?: string
infoText?: string
}) {
const {_} = useLingui()
const copy = useAgeAssuranceCopy()
const {isReady, isAgeRestricted} = useAgeAssurance()
if (!isReady) {
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.Content>
<Layout.Header.TitleText> </Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content />
</Layout.Screen>
)
}
if (!isAgeRestricted) return children
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
{screenTitle ?? <Trans>Unavailable</Trans>}
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<View style={[a.p_lg]}>
<View style={[a.align_start, a.pb_lg]}>
<AgeAssuranceBadge />
</View>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_xl, a.leading_snug, a.font_heavy]}>
<Trans>
You must complete age assurance in order to access this screen.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>{copy.notice}</Text>
</View>
<View
style={[a.flex_row, a.justify_between, a.align_center, a.pb_xl]}>
<Link
label={_(msg`Go to account settings`)}
to="/settings/account"
size="small"
variant="solid"
color="primary"
onPress={() => {
logger.metric('ageAssurance:navigateToSettings', {})
}}>
<ButtonText>
<Trans>Go to account settings</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} position="right" />
</Link>
</View>
{infoText && <Admonition type="tip">{infoText}</Admonition>}
</View>
</Layout.Content>
</Layout.Screen>
)
}
-26
View File
@@ -1,26 +0,0 @@
export const urls = {
kwsHome: 'https://www.kidswebservices.com/en-US',
kwsTermsOfUse: 'https://www.kidswebservices.com/en-US/terms-of-use',
kwsPrivacyPolicy: 'https://www.kidswebservices.com/en-US/privacy-policy',
}
export const KWS_SUPPORTED_LANGS = [
{value: 'en', label: 'English'},
{value: 'ar', label: 'العربية'},
{value: 'zh-Hans', label: '简体中文'},
{value: 'nl', label: 'Nederlands'},
{value: 'tl', label: 'Filipino'},
{value: 'fr', label: 'Français'},
{value: 'de', label: 'Deutsch'},
{value: 'id', label: 'Bahasa Indonesia'},
{value: 'it', label: 'Italiano'},
{value: 'ja', label: '日本語'},
{value: 'ko', label: '한국어'},
{value: 'pt', label: 'Português'},
{value: 'pt-BR', label: 'Português (Brasil)'},
{value: 'ru', label: 'Русский'},
{value: 'es', label: 'Español'},
{value: 'tr', label: 'Türkçe'},
{value: 'th', label: 'ภาษาไทย'},
{value: 'vi', label: 'Tiếng Việt'},
]
@@ -1,21 +0,0 @@
import {useMemo} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function useAgeAssuranceCopy() {
const {_} = useLingui()
return useMemo(() => {
return {
notice: _(
msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`,
),
banner: _(
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
),
chatsInfoText: _(
msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`,
),
}
}, [_])
}
@@ -0,0 +1,259 @@
import {useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {useAgent, useSession} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useBreakpoints, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function ChangeEmailDialog({
control,
verifyEmailControl,
}: {
control: Dialog.DialogControlProps
verifyEmailControl: Dialog.DialogControlProps
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Inner verifyEmailControl={verifyEmailControl} />
</Dialog.Outer>
)
}
export function Inner({
verifyEmailControl,
}: {
verifyEmailControl: Dialog.DialogControlProps
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const agent = useAgent()
const control = Dialog.useDialogContext()
const {gtMobile} = useBreakpoints()
const [currentStep, setCurrentStep] = useState<
'StepOne' | 'StepTwo' | 'StepThree'
>('StepOne')
const [email, setEmail] = useState('')
const [confirmationCode, setConfirmationCode] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState('')
const currentEmail = currentAccount?.email || '(no email)'
const uiStrings = {
StepOne: {
title: _(msg`Change Your Email`),
message: '',
},
StepTwo: {
title: _(msg`Security Step Required`),
message: _(
msg`An email has been sent to your previous address, ${currentEmail}. It includes a confirmation code which you can enter below.`,
),
},
StepThree: {
title: _(msg`Email Updated!`),
message: _(
msg`Your email address has been updated but it is not yet verified. As a next step, please verify your new email.`,
),
},
}
const onRequestChange = async () => {
if (email === currentAccount?.email) {
setError(
_(
msg`The email address you entered is the same as your current email address.`,
),
)
return
}
setError('')
setIsProcessing(true)
try {
const res = await agent.com.atproto.server.requestEmailUpdate()
if (res.data.tokenRequired) {
setCurrentStep('StepTwo')
} else {
await agent.com.atproto.server.updateEmail({email: email.trim()})
await agent.resumeSession(agent.session!)
setCurrentStep('StepThree')
}
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onConfirm = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.updateEmail({
email: email.trim(),
token: confirmationCode.trim(),
})
await agent.resumeSession(agent.session!)
setCurrentStep('StepThree')
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onVerify = async () => {
control.close(() => {
verifyEmailControl.open()
})
}
return (
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={web({maxWidth: 450})}>
<Dialog.Close />
<View style={[a.gap_xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{uiStrings[currentStep].title}
</Text>
{error ? (
<View style={[a.rounded_sm, a.overflow_hidden]}>
<ErrorMessage message={error} />
</View>
) : null}
{currentStep === 'StepOne' ? (
<View>
<TextField.LabelText>
<Trans>Enter your new email address below.</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Input
label={_(msg`New email address`)}
placeholder={_(msg`alice@example.com`)}
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoComplete="email"
/>
</TextField.Root>
</View>
) : (
<Text style={[a.text_md, a.leading_snug]}>
{uiStrings[currentStep].message}
</Text>
)}
</View>
{currentStep === 'StepTwo' ? (
<View>
<TextField.LabelText>
<Trans>Confirmation code</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Input
label={_(msg`Confirmation code`)}
placeholder="XXXXX-XXXXX"
onChangeText={setConfirmationCode}
/>
</TextField.Root>
</View>
) : null}
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
{currentStep === 'StepOne' ? (
<>
<Button
label={_(msg`Request change`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onRequestChange}>
<ButtonText>
<Trans>Request change</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`I have a code`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => setCurrentStep('StepTwo')}>
<ButtonText>
<Trans>I have a code</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepTwo' ? (
<>
<Button
label={_(msg`Confirm`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onConfirm}>
<ButtonText>
<Trans>Confirm</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`Resend email`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => {
setConfirmationCode('')
setCurrentStep('StepOne')
}}>
<ButtonText>
<Trans>Resend email</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepThree' ? (
<>
<Button
label={_(msg`Verify email`)}
variant="solid"
color="primary"
size="large"
onPress={onVerify}>
<ButtonText>
<Trans>Verify email</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Close`)}
variant="solid"
color="secondary"
size="large"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</>
) : null}
</View>
</View>
</Dialog.ScrollableInner>
)
}
-18
View File
@@ -1,6 +1,5 @@
import {createContext, useContext, useMemo, useState} from 'react'
import {type AgeAssuranceRedirectDialogState} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
import * as Dialog from '#/components/Dialog'
import {type Screen} from '#/components/dialogs/EmailDialog/types'
@@ -18,12 +17,6 @@ type ControlsContext = {
signinDialogControl: Control
inAppBrowserConsentControl: StatefulControl<string>
emailDialogControl: StatefulControl<Screen>
linkWarningDialogControl: StatefulControl<{
href: string
displayText: string
share?: boolean
}>
ageAssuranceRedirectDialogControl: StatefulControl<AgeAssuranceRedirectDialogState>
}
const ControlsContext = createContext<ControlsContext | null>(null)
@@ -43,13 +36,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const signinDialogControl = Dialog.useDialogControl()
const inAppBrowserConsentControl = useStatefulDialogControl<string>()
const emailDialogControl = useStatefulDialogControl<Screen>()
const linkWarningDialogControl = useStatefulDialogControl<{
href: string
displayText: string
share?: boolean
}>()
const ageAssuranceRedirectDialogControl =
useStatefulDialogControl<AgeAssuranceRedirectDialogState>()
const ctx = useMemo<ControlsContext>(
() => ({
@@ -57,16 +43,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
signinDialogControl,
inAppBrowserConsentControl,
emailDialogControl,
linkWarningDialogControl,
ageAssuranceRedirectDialogControl,
}),
[
mutedWordsDialogControl,
signinDialogControl,
inAppBrowserConsentControl,
emailDialogControl,
linkWarningDialogControl,
ageAssuranceRedirectDialogControl,
],
)
@@ -1,7 +1,7 @@
import {useEffect, useMemo, useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import {useCallback, useEffect, useState} from 'react'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {useAgent, useSessionApi} from '#/state/session'
import {useAgent} from '#/state/session'
import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events'
export type AccountEmailState = {
@@ -11,36 +11,57 @@ export type AccountEmailState = {
export const accountEmailStateQueryKey = ['accountEmailState'] as const
export function useInvalidateAccountEmailState() {
const qc = useQueryClient()
return useCallback(() => {
return qc.invalidateQueries({
queryKey: accountEmailStateQueryKey,
})
}, [qc])
}
export function useUpdateAccountEmailStateQueryCache() {
const qc = useQueryClient()
return useCallback(
(data: AccountEmailState) => {
return qc.setQueriesData(
{
queryKey: accountEmailStateQueryKey,
},
data,
)
},
[qc],
)
}
export function useAccountEmailState() {
const agent = useAgent()
const {partialRefreshSession} = useSessionApi()
const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
!!agent.session?.emailConfirmed,
)
const state: AccountEmailState = useMemo(
() => ({
isEmailVerified: !!agent.session?.emailConfirmed,
email2FAEnabled: !!agent.session?.emailAuthFactor,
}),
[agent.session],
)
/**
* Only here to refetch on focus, when necessary
*/
useQuery({
const fallbackData: AccountEmailState = {
isEmailVerified: !!agent.session?.emailConfirmed,
email2FAEnabled: !!agent.session?.emailAuthFactor,
}
const query = useQuery<AccountEmailState>({
enabled: !!agent.session,
/**
* Only refetch if the email verification s incomplete.
*/
refetchOnWindowFocus: !prevIsEmailVerified,
refetchOnWindowFocus: true,
queryKey: accountEmailStateQueryKey,
queryFn: async () => {
await partialRefreshSession()
return null
// will also trigger updates to `#/state/session` data
const {data} = await agent.resumeSession(agent.session!)
return {
isEmailVerified: !!data.emailConfirmed,
email2FAEnabled: !!data.emailAuthFactor,
}
},
})
const state = query.data ?? fallbackData
/*
* This will emit `n` times for each instance of this hook. So the listeners
* all use `once` to prevent multiple handlers firing.
@@ -1,10 +1,13 @@
import {useMutation} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session'
import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
export function useConfirmEmail() {
const agent = useAgent()
const {currentAccount} = useSession()
const updateAccountEmailStateQueryCache =
useUpdateAccountEmailStateQueryCache()
return useMutation({
mutationFn: async ({token}: {token: string}) => {
@@ -16,8 +19,11 @@ export function useConfirmEmail() {
email: currentAccount.email,
token: token.trim(),
})
// will update session state at root of app
await agent.resumeSession(agent.session!)
const {data} = await agent.resumeSession(agent.session!)
updateAccountEmailStateQueryCache({
isEmailVerified: !!data.emailConfirmed,
email2FAEnabled: !!data.emailAuthFactor,
})
},
})
}
@@ -1,10 +1,13 @@
import {useMutation} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session'
import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
export function useManageEmail2FA() {
const agent = useAgent()
const {currentAccount} = useSession()
const updateAccountEmailStateQueryCache =
useUpdateAccountEmailStateQueryCache()
return useMutation({
mutationFn: async ({
@@ -22,8 +25,11 @@ export function useManageEmail2FA() {
emailAuthFactor: enabled,
token,
})
// will update session state at root of app
await agent.resumeSession(agent.session!)
const {data} = await agent.resumeSession(agent.session!)
updateAccountEmailStateQueryCache({
isEmailVerified: !!data.emailConfirmed,
email2FAEnabled: !!data.emailAuthFactor,
})
},
})
}
@@ -185,8 +185,8 @@ export function Disable() {
state.emailStatus === 'pending'
? Loader
: state.emailStatus === 'success'
? Check
: Envelope
? Check
: Envelope
}
/>
</Button>
@@ -116,8 +116,8 @@ export function Enable() {
state.status === 'pending'
? Loader
: state.status === 'success'
? Check
: ShieldIcon
? Check
: ShieldIcon
}
/>
</Button>
+4 -5
View File
@@ -5,7 +5,7 @@ import React, {
useRef,
useState,
} from 'react'
import {type TextInput, View} from 'react-native'
import {TextInput, View} from 'react-native'
import {useWindowDimensions} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
@@ -15,14 +15,13 @@ import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection'
import {
type Gif,
tenorUrlToBskyGifUrl,
Gif,
useFeaturedGifsQuery,
useGifSearchQuery,
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {type ListMethods} from '#/view/com/util/List'
import {ListMethods} from '#/view/com/util/List'
import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -317,7 +316,7 @@ export function GifPreview({
t.atoms.bg_contrast_25,
]}
source={{
uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url),
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
-161
View File
@@ -1,161 +0,0 @@
import {useCallback, useMemo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {shareUrl} from '#/lib/sharing'
import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {useGlobalDialogsControlContext} from './Context'
export function LinkWarningDialog() {
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
return (
<Dialog.Outer
control={linkWarningDialogControl.control}
nativeOptions={{preventExpansion: true}}
webOptions={{alignCenter: true}}
onClose={linkWarningDialogControl.clear}>
<Dialog.Handle />
<InAppBrowserConsentInner link={linkWarningDialogControl.value} />
</Dialog.Outer>
)
}
function InAppBrowserConsentInner({
link,
}: {
link?: {href: string; displayText: string; share?: boolean}
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const t = useTheme()
const openLink = useOpenLink()
const {gtMobile} = useBreakpoints()
const potentiallyMisleading = useMemo(
() => link && isPossiblyAUrl(link.displayText),
[link],
)
const onPressVisit = useCallback(() => {
control.close(() => {
if (!link) return
if (link.share) {
shareUrl(link.href)
} else {
openLink(link.href, undefined, true)
}
})
}, [control, link, openLink])
const onCancel = useCallback(() => {
control.close()
}, [control])
return (
<Dialog.ScrollableInner
style={web({maxWidth: 450})}
label={
potentiallyMisleading
? _(msg`Potentially misleading link warning`)
: _(msg`Leaving Bluesky`)
}>
<View style={[a.gap_2xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{potentiallyMisleading ? (
<Trans>Potentially misleading link</Trans>
) : (
<Trans>Leaving Bluesky</Trans>
)}
</Text>
<Text style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}>
<Trans>This link is taking you to the following website:</Trans>
</Text>
{link && <LinkBox href={link.href} />}
{potentiallyMisleading && (
<Text
style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}>
<Trans>Make sure this is where you intend to go!</Trans>
</Text>
)}
</View>
<View
style={[
a.flex_1,
a.gap_sm,
gtMobile && [a.flex_row_reverse, a.justify_start],
]}>
<Button
label={link?.share ? _(msg`Share link`) : _(msg`Visit site`)}
accessibilityHint={_(msg`Opens link ${link?.href ?? ''}`)}
onPress={onPressVisit}
size="large"
variant="solid"
color={potentiallyMisleading ? 'secondary_inverted' : 'primary'}>
<ButtonText>
{link?.share ? (
<Trans>Share link</Trans>
) : (
<Trans>Visit site</Trans>
)}
</ButtonText>
</Button>
<Button
label={_(msg`Go back`)}
onPress={onCancel}
size="large"
variant="ghost"
color="secondary">
<ButtonText>
<Trans>Go back</Trans>
</ButtonText>
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function LinkBox({href}: {href: string}) {
const t = useTheme()
const [scheme, hostname, rest] = useMemo(() => {
try {
const urlp = new URL(href)
const [subdomain, apexdomain] = splitApexDomain(urlp.hostname)
return [
urlp.protocol + '//' + subdomain,
apexdomain,
urlp.pathname.replace(/\/$/, '') + urlp.search + urlp.hash,
]
} catch {
return ['', href, '']
}
}, [href])
return (
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_medium,
a.px_md,
{paddingVertical: 10},
a.rounded_sm,
a.border,
]}>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
{scheme}
<Text style={[a.text_md, a.leading_snug, t.atoms.text, a.font_bold]}>
{hostname}
</Text>
{rest}
</Text>
</View>
)
}
@@ -1,10 +1,6 @@
import React from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedPostgate,
AtUri,
} from '@atproto/api'
import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -26,7 +22,7 @@ import {
import {
createThreadgateViewQueryKey,
getThreadgateView,
type ThreadgateAllowUISetting,
ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
useSetThreadgateAllowMutation,
useThreadgateViewQuery,
@@ -562,8 +558,7 @@ export function usePrefetchPostInteractionSettings({
await Promise.all([
queryClient.prefetchQuery({
queryKey: createPostgateQueryKey(postUri),
queryFn: () =>
getPostgateRecord({agent, postUri}).then(res => res ?? null),
queryFn: () => getPostgateRecord({agent, postUri}),
staleTime: STALE.SECONDS.THIRTY,
}),
queryClient.prefetchQuery({
@@ -390,8 +390,8 @@ function DefaultProfileCard({
!enabled
? {opacity: 0.5}
: pressed || focused || hovered
? t.atoms.bg_contrast_25
: t.atoms.bg,
? t.atoms.bg_contrast_25
: t.atoms.bg,
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
@@ -0,0 +1,360 @@
import {useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Envelope_Filled_Stroke2_Corner0_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ChangeEmailDialog} from './ChangeEmailDialog'
export function VerifyEmailDialog({
control,
onCloseWithoutVerifying,
onCloseAfterVerifying,
reasonText,
changeEmailControl,
reminder,
}: {
control: Dialog.DialogControlProps
onCloseWithoutVerifying?: () => void
onCloseAfterVerifying?: () => void
reasonText?: string
/**
* if a changeEmailControl for a ChangeEmailDialog is not provided,
* this component will create one for you. Using this prop
* helps reduce duplication, since these dialogs are often used together.
*/
changeEmailControl?: Dialog.DialogControlProps
reminder?: boolean
}) {
const agent = useAgent()
const fallbackChangeEmailControl = Dialog.useDialogControl()
const [didVerify, setDidVerify] = useState(false)
return (
<>
<Dialog.Outer
control={control}
onClose={async () => {
if (!didVerify) {
onCloseWithoutVerifying?.()
return
}
try {
await agent.resumeSession(agent.session!)
onCloseAfterVerifying?.()
} catch (e: unknown) {
logger.error(String(e))
return
}
}}>
<Dialog.Handle />
<Inner
setDidVerify={setDidVerify}
reasonText={reasonText}
changeEmailControl={changeEmailControl ?? fallbackChangeEmailControl}
reminder={reminder}
/>
</Dialog.Outer>
{!changeEmailControl && (
<ChangeEmailDialog
control={fallbackChangeEmailControl}
verifyEmailControl={control}
/>
)}
</>
)
}
export function Inner({
setDidVerify,
reasonText,
changeEmailControl,
reminder,
}: {
setDidVerify: (value: boolean) => void
reasonText?: string
changeEmailControl: Dialog.DialogControlProps
reminder?: boolean
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const {currentAccount} = useSession()
const agent = useAgent()
const {gtMobile} = useBreakpoints()
const t = useTheme()
const [currentStep, setCurrentStep] = useState<
'Reminder' | 'StepOne' | 'StepTwo' | 'StepThree'
>(reminder ? 'Reminder' : 'StepOne')
const [confirmationCode, setConfirmationCode] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState('')
const uiStrings = {
Reminder: {
title: _(msg`Please Verify Your Email`),
message: _(
msg`Your email has not yet been verified. This is an important security step which we recommend.`,
),
},
StepOne: {
title: _(msg`Verify Your Email`),
message: '',
},
StepTwo: {
title: _(msg`Enter Code`),
message: _(
msg`An email has been sent! Please enter the confirmation code included in the email below.`,
),
},
StepThree: {
title: _(msg`Success!`),
message: _(msg`Thank you! Your email has been successfully verified.`),
},
}
const onSendEmail = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.requestEmailConfirmation()
setCurrentStep('StepTwo')
} catch (e: unknown) {
setError(cleanError(e))
} finally {
setIsProcessing(false)
}
}
const onVerifyEmail = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.confirmEmail({
email: (currentAccount?.email || '').trim(),
token: confirmationCode.trim(),
})
} catch (e: unknown) {
setError(cleanError(String(e)))
setIsProcessing(false)
return
}
setIsProcessing(false)
setDidVerify(true)
setCurrentStep('StepThree')
}
return (
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={web({maxWidth: 450})}>
<View style={[a.gap_xl]}>
{currentStep === 'Reminder' && (
<View
style={[
a.rounded_sm,
a.align_center,
a.justify_center,
{height: 150},
t.atoms.bg_contrast_100,
]}>
<EnvelopeIcon width={64} fill="white" />
</View>
)}
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{uiStrings[currentStep].title}
</Text>
{error ? (
<View style={[a.rounded_sm, a.overflow_hidden]}>
<ErrorMessage message={error} />
</View>
) : null}
{currentStep === 'StepOne' ? (
<View>
{reasonText ? (
<View style={[a.gap_sm]}>
<Text style={[a.text_md, a.leading_snug]}>{reasonText}</Text>
<Text style={[a.text_md, a.leading_snug]}>
Don't have access to{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
{currentAccount?.email}
</Text>
?{' '}
<InlineLinkText
to="#"
label={_(msg`Change email address`)}
style={[a.text_md, a.leading_snug]}
onPress={e => {
e.preventDefault()
control.close(() => {
changeEmailControl.open()
})
return false
}}>
<Trans>Change your email address</Trans>
</InlineLinkText>
.
</Text>
</View>
) : (
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
You'll receive an email at{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
{currentAccount?.email}
</Text>{' '}
to verify it's you.
</Trans>{' '}
<InlineLinkText
to="#"
label={_(msg`Change email address`)}
style={[a.text_md, a.leading_snug]}
onPress={e => {
e.preventDefault()
control.close(() => {
changeEmailControl.open()
})
return false
}}>
<Trans>Need to change it?</Trans>
</InlineLinkText>
</Text>
)}
</View>
) : (
<Text style={[a.text_md, a.leading_snug]}>
{uiStrings[currentStep].message}
</Text>
)}
</View>
{currentStep === 'StepTwo' ? (
<View>
<TextField.LabelText>
<Trans>Confirmation Code</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Input
label={_(msg`Confirmation code`)}
placeholder="XXXXX-XXXXX"
onChangeText={setConfirmationCode}
/>
</TextField.Root>
</View>
) : null}
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
{currentStep === 'Reminder' ? (
<>
<Button
label={_(msg`Get started`)}
variant="solid"
color="primary"
size="large"
onPress={() => setCurrentStep('StepOne')}>
<ButtonText>
<Trans>Get started</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Maybe later`)}
accessibilityHint={_(msg`Snoozes the reminder`)}
variant="ghost"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => control.close()}>
<ButtonText>
<Trans>Maybe later</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepOne' ? (
<>
<Button
label={_(msg`Send confirmation email`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onSendEmail}>
<ButtonText>
<Trans>Send confirmation</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`I have a code`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => setCurrentStep('StepTwo')}>
<ButtonText>
<Trans>I have a code</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepTwo' ? (
<>
<Button
label={_(msg`Confirm`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onVerifyEmail}>
<ButtonText>
<Trans>Confirm</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`Resend email`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => {
setConfirmationCode('')
setCurrentStep('StepOne')
}}>
<ButtonText>
<Trans>Resend email</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepThree' ? (
<Button
label={_(msg`Close`)}
variant="solid"
color="primary"
size="large"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
) : null}
</View>
</View>
</Dialog.ScrollableInner>
)
}
@@ -1,177 +0,0 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
export function ActivitySubscriptionsNUX() {
const t = useTheme()
const {_} = useLingui()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
Dialog.useAutoOpen(control)
const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
return (
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Introducing activity notifications`)}
style={[web({maxWidth: 400})]}
contentContainerStyle={[
{
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
},
]}>
<View
style={[
a.align_center,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{
gap: isWeb ? 16 : 24,
paddingTop: isWeb ? 24 : 48,
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]}>
<View
style={[
a.pl_sm,
a.pr_md,
a.py_sm,
a.rounded_full,
a.flex_row,
a.align_center,
a.gap_xs,
{
backgroundColor: t.palette.primary_100,
},
]}>
<SparkleIcon fill={t.palette.primary_800} size="sm" />
<Text
style={[
a.font_bold,
{
color: t.palette.primary_800,
},
]}>
<Trans>New Feature</Trans>
</Text>
</View>
<View style={[a.relative, a.w_full]}>
<View
style={[
a.absolute,
t.atoms.bg_contrast_25,
t.atoms.shadow_md,
{
shadowOpacity: 0.4,
top: 5,
bottom: 0,
left: '17%',
right: '17%',
width: '66%',
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
]}
/>
<View
style={[
a.overflow_hidden,
{
aspectRatio: 398 / 228,
},
]}>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/activity_notifications_announcement.webp')}
style={[
a.w_full,
{
aspectRatio: 398 / 268,
},
]}
alt={_(
msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`,
)}
/>
</View>
</View>
</View>
<View
style={[
a.align_center,
a.px_xl,
isWeb ? [a.pt_xl, a.gap_xl, a.pb_sm] : [a.pt_3xl, a.gap_3xl],
]}>
<View style={[a.gap_md, a.align_center]}>
<Text
style={[
a.text_3xl,
a.leading_tight,
a.font_heavy,
a.text_center,
{
fontSize: isWeb ? 28 : 32,
maxWidth: 300,
},
]}>
<Trans>Get notified when someone posts</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
{
maxWidth: 340,
},
]}>
<Trans>
You can now choose to be notified when specific people post. If
theres someone you want timely updates from, go to their
profile and find the new bell icon near the follow button.
</Trans>
</Text>
</View>
{!isWeb && (
<Button
label={_(msg`Close`)}
size="large"
variant="solid"
color="primary"
onPress={() => {
control.close()
}}
style={[a.w_full, {maxWidth: 280}]}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+9 -10
View File
@@ -11,12 +11,12 @@ import {
import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions'
import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement'
/*
* NUXs
*/
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {isExistingUserAsOf} from '#/components/dialogs/nuxs/utils'
import {isDaysOld} from '#/components/dialogs/nuxs/utils'
type Context = {
activeNux: Nux | undefined
@@ -33,12 +33,9 @@ const queuedNuxs: {
}) => boolean
}[] = [
{
id: Nux.ActivitySubscriptions,
id: Nux.InitialVerificationAnnouncement,
enabled: ({currentProfile}) => {
return isExistingUserAsOf(
'2025-07-07T00:00:00.000Z',
currentProfile.createdAt,
)
return isDaysOld(2, currentProfile.createdAt)
},
},
]
@@ -114,7 +111,7 @@ function Inner({
}
React.useEffect(() => {
if (snoozed) return // comment this out to test
if (snoozed) return
if (!nuxs) return
for (const {id, enabled} of queuedNuxs) {
@@ -122,7 +119,7 @@ function Inner({
// check if completed first
if (nux && nux.completed) {
continue // comment this out to test
continue
}
// then check gate (track exposure)
@@ -175,7 +172,9 @@ function Inner({
return (
<Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.ActivitySubscriptions && <ActivitySubscriptionsNUX />}
{activeNux === Nux.InitialVerificationAnnouncement && (
<InitialVerificationAnnouncement />
)}
</Context.Provider>
)
}
-15
View File
@@ -16,18 +16,3 @@ export function isDaysOld(days: number, createdAt?: string) {
if (isOldEnough) return true
return false
}
export function isExistingUserAsOf(date: string, createdAt?: string) {
/*
* Should never happen because we gate NUXs to only accounts with a valid
* profile and a `createdAt` (see `nuxs/index.tsx`). But if it ever did, the
* account is either old enough to be pre-onboarding, or some failure happened
* during account creation. Fail closed. - esb
*/
if (!createdAt) return false
const threshold = Date.parse(date)
const then = new Date(createdAt).getTime()
return then < threshold
}
+2 -2
View File
@@ -98,8 +98,8 @@ export function EmojiReactionPicker({
: t.palette.primary_500,
}
: alreadyReacted
? {backgroundColor: t.palette.primary_200}
: bgColor,
? {backgroundColor: t.palette.primary_200}
: bgColor,
{height: 40, width: 40},
a.justify_center,
a.align_center,

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