Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 052a3e4f41 | |||
| 29f8bc998d | |||
| 4760e59ea4 | |||
| d9e37a222a | |||
| 3b98f2f8ab | |||
| ab85b509c0 | |||
| 65faee4f08 | |||
| 94be661794 | |||
| 10aa46aa9e | |||
| 8f82ad66df | |||
| 1cb362b596 | |||
| f960d2fbc6 | |||
| 58ac9a3e7b | |||
| bbab7695f9 | |||
| 26aa7c0d9a | |||
| 498a09e797 | |||
| 2223babc2b | |||
| 5922c6622f | |||
| e684c21572 | |||
| 226b19ae43 | |||
| cb6e65e313 | |||
| af92610864 | |||
| ca905eb3e1 | |||
| 61c919110e | |||
| 9aa360d328 | |||
| 2d8de1ddc3 | |||
| c540dae4e7 | |||
| 2818c7f508 | |||
| c4fd9980cc | |||
| 966ae68dd2 | |||
| d2aea9161b | |||
| edb19dd6cf | |||
| 85a616eee0 | |||
| 7285157ab5 | |||
| b3f775d1d8 | |||
| a1857d62bd | |||
| d82592f072 | |||
| dae03312c9 | |||
| c2e5a18e42 | |||
| 2efc047f64 | |||
| 80049bbe82 | |||
| e627d37f42 | |||
| 24fe45aaa1 | |||
| 0deb9511af | |||
| 43ebb80a5b | |||
| d91e206e21 | |||
| 493bd6dcf6 | |||
| 4f6ff6fc73 | |||
| 7ba2997af5 | |||
| 4b6fbcc050 | |||
| 55806f1087 | |||
| 0aaf11aa7b | |||
| 9cfa7d0ba8 | |||
| 9743149c26 | |||
| 82877a096d | |||
| 12dc38c5fc | |||
| 7d5de60401 | |||
| 8136258cd8 | |||
| afe5db8b57 | |||
| 893dd78a00 | |||
| b38013a12f | |||
| aa1395542f | |||
| caa02db87b | |||
| f390167f66 | |||
| 6275d2e0fd | |||
| 3e1e1ba00e | |||
| 699fdbac01 | |||
| a14a9be127 | |||
| 998ee78e3d | |||
| a355eedf85 | |||
| 89cffdf5d0 | |||
| e9524d8a83 | |||
| f50b70e2b4 | |||
| e80e2f66c3 | |||
| f02b9c323f | |||
| 2200cc95d8 | |||
| f45e58e057 | |||
| da87515d0a | |||
| 348e7fa379 | |||
| df4e888347 | |||
| b48ca13172 | |||
| 3485dd37c2 | |||
| c9ce214a66 | |||
| 9971e91f9c | |||
| 8cee40beff | |||
| 4ffc1263e7 | |||
| ad7d49ef8f | |||
| d3a6db4594 | |||
| d3923c1f2e | |||
| 8d5b1c83cc | |||
| 37a73b1755 | |||
| a3fc082148 | |||
| fcc4018971 | |||
| 506bad83ba | |||
| f7e61056f5 | |||
| 1fded4e914 |
@@ -34,5 +34,8 @@ EXPO_PUBLIC_SENTRY_DSN=
|
||||
# Bitdrift API key. If undefined, Bitdrift will be disabled.
|
||||
EXPO_PUBLIC_BITDRIFT_API_KEY=
|
||||
|
||||
# bapp-config web worker URL
|
||||
BAPP_CONFIG_DEV_URL=
|
||||
# geolocation web worker URL
|
||||
GEOLOCATION_DEV_URL=
|
||||
|
||||
# live-events web worker URL
|
||||
LIVE_EVENTS_DEV_URL=
|
||||
|
||||
@@ -35,6 +35,7 @@ module.exports = {
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
'Toast.Action',
|
||||
'toast.Action',
|
||||
'AgeAssuranceAdmonition',
|
||||
'Span',
|
||||
'StackedButton',
|
||||
|
||||
@@ -16,6 +16,9 @@ jobs:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit Android
|
||||
runs-on: Linux-x64-32core
|
||||
concurrency:
|
||||
group: android-build
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -79,13 +82,15 @@ jobs:
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight-android' }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p android
|
||||
--profile ${{ inputs.profile || 'testflight-android' }}
|
||||
--profile $PROFILE
|
||||
--local --output build.aab --non-interactive
|
||||
|
||||
- name: ✍️ Rename Testflight bundle
|
||||
@@ -194,4 +199,6 @@ jobs:
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
@@ -16,6 +16,9 @@ jobs:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-26-xlarge
|
||||
concurrency:
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -91,13 +94,15 @@ jobs:
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight' }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p ios
|
||||
--profile ${{ inputs.profile || 'testflight' }}
|
||||
--profile $PROFILE
|
||||
--local --output build.tar.gz --non-interactive
|
||||
|
||||
- name: 📂 Extract build artifact
|
||||
@@ -147,5 +152,7 @@ jobs:
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
@@ -39,10 +39,12 @@ jobs:
|
||||
|
||||
# Validate the version if one is supplied. This should generally happen if the update is for a production client
|
||||
- name: 🧐 Validate version
|
||||
env:
|
||||
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
||||
if: ${{ inputs.runtimeVersion }}
|
||||
run: |
|
||||
if [ -z "${{ inputs.runtimeVersion }}" ]; then
|
||||
[[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
|
||||
if [ -z "$RUNTIME_VERSION" ]; then
|
||||
[[ "$RUNTIME_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
@@ -103,12 +105,15 @@ jobs:
|
||||
|
||||
# eas.json not used here, set EXPO_PUBLIC_ENV
|
||||
- name: Env
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel || 'testflight' }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
id: env
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_ENV=${{ inputs.channel || 'testflight' }}" >> .env
|
||||
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
|
||||
@@ -145,15 +150,15 @@ jobs:
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
# GitHub actions are horrible so let's just copy paste this in
|
||||
buildIfNecessaryIOS:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-26
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
|
||||
cancel-in-progress: true
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
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
|
||||
@@ -249,13 +254,15 @@ jobs:
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ inputs.channel == 'testflight' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
buildIfNecessaryAndroid:
|
||||
name: Build and Submit Android
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
|
||||
group: android-build
|
||||
cancel-in-progress: false
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
@@ -363,5 +370,7 @@ jobs:
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
# NOTE(sfn): we can add a custom system prompt here
|
||||
|
||||
claude_args: |
|
||||
--model claude-opus-4-5-20251101
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
fi
|
||||
|
||||
|
||||
if [[ "${{ github.event.comment.body }}" == *"ota"* ]]; then
|
||||
if [[ "$COMMENT" == *"ota"* ]]; then
|
||||
has_ota=true
|
||||
else
|
||||
has_ota=false
|
||||
@@ -75,6 +75,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Get PR HEAD SHA
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
id: pr-info
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -82,7 +84,7 @@ jobs:
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: ${{ github.event.issue.number }}
|
||||
pull_number: process.env.ISSUE_NUMBER,
|
||||
});
|
||||
|
||||
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
|
||||
@@ -184,13 +186,15 @@ jobs:
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
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 either scanning the QR code or opening the deep link below in your browser:
|
||||
|
||||
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.issue.number }}" width=300 height=300>
|
||||
<img src="https://bsky-qr.vercel.app?channel=pull-request-$ISSUE_NUMBER" width=300 height=300>
|
||||
|
||||
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
|
||||
---
|
||||
|
||||
@@ -35,20 +35,27 @@ jobs:
|
||||
cache: yarn
|
||||
|
||||
- name: Ensure tracking relevant branches and checkout base
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git checkout ${{ github.head_ref }}
|
||||
git checkout ${{ github.base_ref }}
|
||||
git checkout $HEAD_REF
|
||||
git checkout $BASE_REF
|
||||
|
||||
- name: Get the base commit
|
||||
id: base-commit
|
||||
run: echo base-commit=$(git log -n 1 ${{ github.base_ref }} --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: echo base-commit=$(git log -n 1 $BASE_REF --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Merge PR commit
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
# Have to set a git config for the merge to work
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git merge --no-edit ${{ github.head_ref }}
|
||||
git merge --no-edit $HEAD_REF
|
||||
yarn install
|
||||
yarn intl:build
|
||||
|
||||
@@ -84,7 +91,7 @@ jobs:
|
||||
with:
|
||||
base_path: "stats-base.json"
|
||||
pr_path: "../stats-new.json"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
|
||||
@@ -17,7 +17,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch base branch
|
||||
run: git fetch origin ${{ github.base_ref }} --depth=1
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git fetch origin $BASE_REF --depth=1
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
@@ -25,7 +27,9 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Reset yarn.lock to base
|
||||
run: git show "origin/${{ github.base_ref }}:yarn.lock" > yarn.lock
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git show "origin/$BASE_REF:yarn.lock" > yarn.lock
|
||||
|
||||
- name: Yarn install
|
||||
uses: Wandalen/wretry.action@master
|
||||
|
||||
@@ -124,3 +124,4 @@ bskyogcard/src/assets/fonts/noto-*
|
||||
bskyweb/static/media/*.webp
|
||||
bskyweb/static/media/*.jpg
|
||||
bskyweb/static/media/*.png
|
||||
bskyweb/static/media/*.svg
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
# CLAUDE.md - Bluesky Social App Development Guide
|
||||
|
||||
This document provides guidance for working effectively in the Bluesky Social app codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
|
||||
|
||||
**Tech Stack:**
|
||||
- React Native 0.81 with Expo 54
|
||||
- TypeScript
|
||||
- React Navigation for routing
|
||||
- TanStack Query (React Query) for data fetching
|
||||
- Lingui for internationalization
|
||||
- Custom design system called ALF (Application Layout Framework)
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
yarn start # Start Expo dev server
|
||||
yarn web # Start web version
|
||||
yarn android # Run on Android
|
||||
yarn ios # Run on iOS
|
||||
|
||||
# Testing & Quality
|
||||
yarn test # Run Jest tests
|
||||
yarn lint # Run ESLint
|
||||
yarn typecheck # Run TypeScript type checking
|
||||
|
||||
# Internationalization
|
||||
yarn intl:extract # Extract translation strings
|
||||
yarn intl:compile # Compile translations for runtime
|
||||
|
||||
# Build
|
||||
yarn build-web # Build web version
|
||||
yarn prebuild # Generate native projects
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── alf/ # Design system (ALF) - themes, atoms, tokens
|
||||
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
|
||||
├── screens/ # Full-page screen components (newer pattern)
|
||||
├── view/
|
||||
│ ├── screens/ # Full-page screens (legacy location)
|
||||
│ ├── com/ # Reusable view components
|
||||
│ └── shell/ # App shell (navigation bars, tabs)
|
||||
├── state/
|
||||
│ ├── queries/ # TanStack Query hooks
|
||||
│ ├── preferences/ # User preferences (React Context)
|
||||
│ ├── session/ # Authentication state
|
||||
│ └── persisted/ # Persistent storage layer
|
||||
├── lib/ # Utilities, constants, helpers
|
||||
├── locale/ # i18n configuration and language files
|
||||
└── Navigation.tsx # Main navigation configuration
|
||||
```
|
||||
|
||||
## Styling System (ALF)
|
||||
|
||||
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
function MyComponent() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
Hello
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
**Static Atoms** - Theme-independent styles imported from `atoms`:
|
||||
```tsx
|
||||
import {atoms as a} from '#/alf'
|
||||
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
|
||||
```
|
||||
|
||||
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
|
||||
```tsx
|
||||
const t = useTheme()
|
||||
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
|
||||
// t.palette.primary_500, t.palette.negative_400, etc.
|
||||
```
|
||||
|
||||
**Platform Utilities** - For platform-specific styles:
|
||||
```tsx
|
||||
import {web, native, ios, android, platform} from '#/alf'
|
||||
|
||||
const styles = [
|
||||
a.p_md,
|
||||
web({cursor: 'pointer'}),
|
||||
native({paddingBottom: 20}),
|
||||
platform({ios: {...}, android: {...}, web: {...}}),
|
||||
]
|
||||
```
|
||||
|
||||
**Breakpoints** - Responsive design:
|
||||
```tsx
|
||||
import {useBreakpoints} from '#/alf'
|
||||
|
||||
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
|
||||
if (gtMobile) {
|
||||
// Tablet or desktop layout
|
||||
}
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Spacing: `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `xxl` (t-shirt sizes)
|
||||
- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl`
|
||||
- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl`
|
||||
- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between`
|
||||
- Borders: `border`, `border_t`, `rounded_md`, `rounded_full`
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Dialog Component
|
||||
|
||||
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
|
||||
|
||||
```tsx
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
function MyFeature() {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button label="Open" onPress={control.open}>
|
||||
<ButtonText>Open Dialog</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle /> {/* Native drag handle */}
|
||||
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
|
||||
<Dialog.Header>
|
||||
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
||||
</Dialog.Header>
|
||||
|
||||
<Text>Dialog content here</Text>
|
||||
|
||||
<Button label="Close" onPress={() => control.close()}>
|
||||
<ButtonText>Close</ButtonText>
|
||||
</Button>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Menu Component
|
||||
|
||||
Menus render as a dropdown on web and a bottom sheet dialog on native.
|
||||
|
||||
```tsx
|
||||
import * as Menu from '#/components/Menu'
|
||||
|
||||
function MyMenu() {
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label="Open menu">
|
||||
{({props}) => (
|
||||
<Button {...props} label="Menu">
|
||||
<ButtonIcon icon={DotsHorizontal} />
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item label="Edit" onPress={handleEdit}>
|
||||
<Menu.ItemIcon icon={Pencil} />
|
||||
<Menu.ItemText>Edit</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
<Menu.Item label="Delete" onPress={handleDelete}>
|
||||
<Menu.ItemIcon icon={Trash} />
|
||||
<Menu.ItemText>Delete</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Button Component
|
||||
|
||||
```tsx
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
|
||||
// Solid primary button (most common)
|
||||
<Button label="Save" onPress={handleSave} color="primary" size="large">
|
||||
<ButtonText>Save</ButtonText>
|
||||
</Button>
|
||||
|
||||
// With icon
|
||||
<Button label="Share" onPress={handleShare} color="secondary" size="small">
|
||||
<ButtonIcon icon={Share} />
|
||||
<ButtonText>Share</ButtonText>
|
||||
</Button>
|
||||
|
||||
// Icon-only button
|
||||
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
|
||||
// Ghost variant (deprecated - use color prop)
|
||||
<Button label="Cancel" variant="ghost" color="secondary" size="small">
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Button Props:**
|
||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'`
|
||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
|
||||
|
||||
### Typography
|
||||
|
||||
```tsx
|
||||
import {Text, H1, H2, P} from '#/components/Typography'
|
||||
|
||||
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
|
||||
<P>Paragraph text with default styling.</P>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
|
||||
|
||||
// For text with emoji, add the emoji prop
|
||||
<Text emoji>Hello! 👋</Text>
|
||||
```
|
||||
|
||||
### TextField
|
||||
|
||||
```tsx
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
|
||||
<TextField.LabelText>Email</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={AtSign} />
|
||||
<TextField.Input
|
||||
label="Email address"
|
||||
placeholder="you@example.com"
|
||||
defaultValue={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</TextField.Root>
|
||||
```
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
All user-facing strings must be wrapped for translation using Lingui.
|
||||
|
||||
```tsx
|
||||
import {msg, Trans, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
function MyComponent() {
|
||||
const {_} = useLingui()
|
||||
|
||||
// Simple strings - use msg() with _() function
|
||||
const title = _(msg`Settings`)
|
||||
const errorMessage = _(msg`Something went wrong`)
|
||||
|
||||
// Strings with variables
|
||||
const greeting = _(msg`Hello, ${name}!`)
|
||||
|
||||
// Pluralization
|
||||
const countLabel = _(plural(count, {
|
||||
one: '# item',
|
||||
other: '# items',
|
||||
}))
|
||||
|
||||
// JSX content - use Trans component
|
||||
return (
|
||||
<Text>
|
||||
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
yarn intl:extract # Extract new strings to locale files
|
||||
yarn intl:compile # Compile for runtime (required after changes)
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### TanStack Query (Data Fetching)
|
||||
|
||||
```tsx
|
||||
// src/state/queries/profile.ts
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
// Query key pattern
|
||||
const RQKEY_ROOT = 'profile'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
// Query hook
|
||||
export function useProfileQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useQuery({
|
||||
queryKey: RQKEY(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.getProfile({actor: did})
|
||||
return res.data
|
||||
},
|
||||
staleTime: STALE.MINUTES.FIVE,
|
||||
enabled: !!did,
|
||||
})
|
||||
}
|
||||
|
||||
// Mutation hook
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => {
|
||||
// Update logic
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Stale Time Constants** (from `src/state/queries/index.ts`):
|
||||
```tsx
|
||||
STALE.SECONDS.FIFTEEN // 15 seconds
|
||||
STALE.MINUTES.ONE // 1 minute
|
||||
STALE.MINUTES.FIVE // 5 minutes
|
||||
STALE.HOURS.ONE // 1 hour
|
||||
STALE.INFINITY // Never stale
|
||||
```
|
||||
|
||||
### Preferences (React Context)
|
||||
|
||||
```tsx
|
||||
// Simple boolean preference pattern
|
||||
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
|
||||
|
||||
function SettingsScreen() {
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
const setAutoplayDisabled = useSetAutoplayDisabled()
|
||||
|
||||
return (
|
||||
<Toggle
|
||||
value={autoplayDisabled}
|
||||
onValueChange={setAutoplayDisabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Session State
|
||||
|
||||
```tsx
|
||||
import {useSession, useAgent} from '#/state/session'
|
||||
|
||||
function MyComponent() {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
if (!hasSession) {
|
||||
return <LoginPrompt />
|
||||
}
|
||||
|
||||
// Use agent for API calls
|
||||
const response = await agent.getProfile({actor: currentAccount.did})
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
Navigation uses React Navigation with type-safe route parameters.
|
||||
|
||||
```tsx
|
||||
// Screen component
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||
|
||||
export function ProfileScreen({route, navigation}: Props) {
|
||||
const {name} = route.params // Type-safe params
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
{/* Screen content */}
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
// Programmatic navigation
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
const navigation = useNavigation()
|
||||
navigation.navigate('Profile', {name: 'alice.bsky.social'})
|
||||
|
||||
// Or use the navigate helper
|
||||
import {navigate} from '#/Navigation'
|
||||
navigate('Profile', {name: 'alice.bsky.social'})
|
||||
```
|
||||
|
||||
## Platform-Specific Code
|
||||
|
||||
Use file extensions for platform-specific implementations:
|
||||
|
||||
```
|
||||
Component.tsx # Shared/default
|
||||
Component.web.tsx # Web-only
|
||||
Component.native.tsx # iOS + Android
|
||||
Component.ios.tsx # iOS-only
|
||||
Component.android.tsx # Android-only
|
||||
```
|
||||
|
||||
Example from Dialog:
|
||||
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
|
||||
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
|
||||
|
||||
Platform detection:
|
||||
```tsx
|
||||
import {isWeb, isNative, isIOS, isAndroid} from '#/platform/detection'
|
||||
|
||||
if (isNative) {
|
||||
// Native-specific logic
|
||||
}
|
||||
```
|
||||
|
||||
## Import Aliases
|
||||
|
||||
Always use the `#/` alias for absolute imports:
|
||||
|
||||
```tsx
|
||||
// Good
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
|
||||
// Avoid
|
||||
import {useSession} from '../../../state/session'
|
||||
```
|
||||
|
||||
## Footguns
|
||||
|
||||
Common pitfalls to avoid in this codebase:
|
||||
|
||||
### Dialog Close Callback (Critical)
|
||||
|
||||
**Always use `control.close(() => ...)` when performing actions after closing a dialog.** The callback ensures the action runs after the dialog's close animation completes. Failing to do this causes race conditions with React state updates.
|
||||
|
||||
```tsx
|
||||
// WRONG - causes bugs with state updates, navigation, opening other dialogs
|
||||
const onConfirm = () => {
|
||||
control.close()
|
||||
navigation.navigate('Home') // May race with dialog animation
|
||||
}
|
||||
|
||||
// WRONG - same problem
|
||||
const onConfirm = () => {
|
||||
control.close()
|
||||
otherDialogControl.open() // Will likely fail or cause visual glitches
|
||||
}
|
||||
|
||||
// CORRECT - action runs after dialog fully closes
|
||||
const onConfirm = () => {
|
||||
control.close(() => {
|
||||
navigation.navigate('Home')
|
||||
})
|
||||
}
|
||||
|
||||
// CORRECT - opening another dialog after close
|
||||
const onConfirm = () => {
|
||||
control.close(() => {
|
||||
otherDialogControl.open()
|
||||
})
|
||||
}
|
||||
|
||||
// CORRECT - state updates after close
|
||||
const onConfirm = () => {
|
||||
control.close(() => {
|
||||
setSomeState(newValue)
|
||||
onCallback?.()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
This applies to:
|
||||
- Navigation (`navigation.navigate()`, `navigation.push()`)
|
||||
- Opening other dialogs or menus
|
||||
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
|
||||
- Callbacks passed from parent components
|
||||
|
||||
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
|
||||
|
||||
### Controlled vs Uncontrolled Inputs
|
||||
|
||||
Prefer `defaultValue` over `value` for TextInput on the old architecture:
|
||||
|
||||
```tsx
|
||||
// Preferred - uncontrolled
|
||||
<TextField.Input
|
||||
defaultValue={initialEmail}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
|
||||
// Avoid when possible - controlled (can cause performance issues)
|
||||
<TextField.Input
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
```
|
||||
|
||||
### Platform-Specific Behavior
|
||||
|
||||
Some components behave differently across platforms:
|
||||
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
|
||||
- `Dialog.Close` - Only renders on web (X button)
|
||||
- `Menu.Divider` - Only renders on web
|
||||
- `Menu.ContainerItem` - Only works on native
|
||||
|
||||
Always test on multiple platforms when using these components.
|
||||
|
||||
### React Compiler is Enabled
|
||||
|
||||
This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically.
|
||||
|
||||
```tsx
|
||||
// UNNECESSARY - React Compiler handles this
|
||||
const handlePress = useCallback(() => {
|
||||
doSomething()
|
||||
}, [doSomething])
|
||||
|
||||
// JUST WRITE THIS
|
||||
const handlePress = () => {
|
||||
doSomething()
|
||||
}
|
||||
```
|
||||
|
||||
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
|
||||
- The value is immediately used in an effect's dependency array
|
||||
- You're passing a callback to a non-React library that needs referential stability
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
|
||||
|
||||
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
|
||||
|
||||
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
|
||||
|
||||
4. **State**: Use TanStack Query for server state, React Context for UI preferences
|
||||
|
||||
5. **Components**: Check if a component exists in `#/components/` before creating new ones
|
||||
|
||||
6. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens
|
||||
|
||||
7. **Testing**: Components should have `testID` props for E2E testing
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Purpose | Location |
|
||||
|---------|----------|
|
||||
| Theme definitions | `src/alf/themes.ts` |
|
||||
| Design tokens | `src/alf/tokens.ts` |
|
||||
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
|
||||
| Navigation config | `src/Navigation.tsx` |
|
||||
| Route definitions | `src/routes.ts` |
|
||||
| Route types | `src/lib/routes/types.ts` |
|
||||
| Query hooks | `src/state/queries/*.ts` |
|
||||
| Session state | `src/state/session/index.tsx` |
|
||||
| i18n setup | `src/locale/i18n.ts` |
|
||||
@@ -389,6 +389,13 @@ module.exports = function (_config) {
|
||||
],
|
||||
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
|
||||
['expo-location'],
|
||||
[
|
||||
'expo-contacts',
|
||||
{
|
||||
contactsPermission:
|
||||
'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.',
|
||||
},
|
||||
],
|
||||
].filter(Boolean),
|
||||
extra: {
|
||||
eas: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M2.972 12a9 9 0 0 1 9-9c1.726 0 3.165.423 4.448 1.21A10 10 0 0 1 18 5.428V4a1 1 0 1 1 2 0v4a1 1 0 0 1-1 1h-4a1 1 0 1 1 0-2h1.756a8.3 8.3 0 0 0-1.382-1.085C14.417 5.327 13.341 5 11.972 5a7 7 0 1 0 6.601 9.333A1 1 0 0 1 20.46 15a9 9 0 0 1-17.487-3Z"/></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 2a3 3 0 0 0-3 3v14.5A2.5 2.5 0 0 0 6.5 22H19a1 1 0 1 0 0-2H6.5a.5.5 0 0 1 0-1H19a1 1 0 0 0 1-1V4a2 2 0 0 0-2-2H7Zm5 5a1.75 1.75 0 1 0 0 3.5A1.75 1.75 0 0 0 12 7Zm0 4c-.894 0-1.57.188-2.068.512a2.07 2.07 0 0 0-.852 1.058c-.286.838.432 1.43 1.03 1.43h3.78c.598 0 1.316-.592 1.03-1.43a2.07 2.07 0 0 0-.852-1.058C13.57 11.189 12.894 11 12 11Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 484 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M7 4a1 1 0 0 0-1 1v12.05q.243-.05.5-.05H18V4H7Zm5 7c.894 0 1.57.188 2.068.513a2.1 2.1 0 0 1 .806.937l.046.12c.285.836-.43 1.43-1.03 1.43h-3.78c-.6 0-1.315-.594-1.03-1.43l.046-.12a2.1 2.1 0 0 1 .806-.937C10.43 11.188 11.106 11 12 11Zm0-4a1.75 1.75 0 1 1 0 3.5A1.75 1.75 0 0 1 12 7Zm8 11a1 1 0 0 1-1 1H6.5a.5.5 0 0 0 0 1H19a1 1 0 1 1 0 2H6.5A2.5 2.5 0 0 1 4 19.5V5a3 3 0 0 1 3-3h11a2 2 0 0 1 2 2v14Z"/></svg>
|
||||
|
After Width: | Height: | Size: 500 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 12a8 8 0 1 0-8 8c2.204 0 4.2-.89 5.648-2.333A7.97 7.97 0 0 0 20 12Zm-5.707-3.707a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 1 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 1 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 1 1 1.414-1.414L12 10.586l2.293-2.293ZM22 12a9.96 9.96 0 0 1-2.269 6.34l2.891 2.89a1 1 0 1 1-1.414 1.415l-2.893-2.893A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10Z"/></svg>
|
||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M55 32C55 19.298 44.703 9 32 9S9 19.298 9 32s10.298 23 23 23a22.93 22.93 0 0 0 16.235-6.708A22.93 22.93 0 0 0 55 32Zm-15.707-8.707a1 1 0 1 1 1.414 1.414L33.414 32l7.293 7.293a1 1 0 1 1-1.414 1.414L32 33.414l-7.293 7.293a1 1 0 1 1-1.414-1.414L30.586 32l-7.293-7.293a1 1 0 1 1 1.414-1.414L32 30.586l7.293-7.293ZM57 32a24.9 24.9 0 0 1-6.66 16.985l8.808 8.808a1 1 0 0 1-1.414 1.414l-8.81-8.81A24.9 24.9 0 0 1 32 57C18.193 57 7 45.807 7 32S18.193 7 32 7s25 11.193 25 25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M24.952 33.435c10.897 0 19.195 6.496 22.58 15.65.784 2.117.258 4.183-1 5.683-1.242 1.48-3.194 2.414-5.33 2.415h-32.5c-2.136 0-4.089-.935-5.331-2.415-1.258-1.5-1.783-3.566-1-5.682 3.386-9.155 11.683-15.651 22.58-15.651Zm0 2.298c-9.885 0-17.355 5.849-20.425 14.15-.47 1.271-.174 2.48.604 3.407.795.947 2.096 1.594 3.57 1.594h32.5c1.476 0 2.777-.647 3.571-1.594.778-.928 1.074-2.136.605-3.406-3.07-8.302-10.54-14.15-20.425-14.15Zm33.262-14.59a1.15 1.15 0 1 1 1.625 1.626l-5.688 5.687 5.686 5.688a1.15 1.15 0 1 1-1.625 1.625l-5.686-5.688-5.687 5.688a1.15 1.15 0 1 1-1.625-1.625l5.687-5.688-5.689-5.687a1.15 1.15 0 1 1 1.625-1.625l5.689 5.687 5.688-5.687ZM24.949 5.552c6.557 0 11.874 5.316 11.874 11.874 0 6.557-5.317 11.874-11.874 11.874s-11.874-5.317-11.874-11.874S18.39 5.55 24.949 5.55Zm0 2.299a9.575 9.575 0 0 0-9.575 9.575A9.575 9.575 0 0 0 24.949 27a9.575 9.575 0 0 0 9.575-9.575A9.575 9.575 0 0 0 24.95 7.85Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1014 B |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -9,7 +9,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
@@ -40,6 +42,11 @@ type Server struct {
|
||||
cfg *Config
|
||||
|
||||
ipccClient http.Client
|
||||
|
||||
// sitemapClient is used for fetching sitemaps from the appview. It has
|
||||
// DisableCompression set to true so that gzipped responses are passed
|
||||
// through without being decompressed.
|
||||
sitemapClient http.Client
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -116,6 +123,16 @@ func serve(cctx *cli.Context) error {
|
||||
},
|
||||
},
|
||||
},
|
||||
sitemapClient: http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
DisableCompression: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create the HTTP server.
|
||||
@@ -291,7 +308,6 @@ func serve(cctx *cli.Context) error {
|
||||
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)
|
||||
e.GET("/sys/log", server.WebGeneric)
|
||||
@@ -337,6 +353,10 @@ func serve(cctx *cli.Context) error {
|
||||
// ipcc
|
||||
e.GET("/ipcc", server.WebIpCC)
|
||||
|
||||
// sitemap handlers
|
||||
e.GET("/sitemap/users.xml.gz", server.handleSitemapUsersIndex)
|
||||
e.GET("/sitemap/users/*", server.handleSitemapUsersSubpage)
|
||||
|
||||
if linkHost != "" {
|
||||
linkUrl, err := url.Parse(linkHost)
|
||||
if err != nil {
|
||||
@@ -753,3 +773,54 @@ func (srv *Server) WebIpCC(c echo.Context) error {
|
||||
}
|
||||
return c.JSON(200, outResponse)
|
||||
}
|
||||
|
||||
func (srv *Server) handleSitemapUsersIndex(c echo.Context) error {
|
||||
url := fmt.Sprintf("%s/external/sitemap/users.xml.gz", srv.cfg.appviewHost)
|
||||
return srv.serveSitemapRequest(c, url, "user index")
|
||||
}
|
||||
|
||||
func (srv *Server) handleSitemapUsersSubpage(c echo.Context) error {
|
||||
path := c.Param("*")
|
||||
url := fmt.Sprintf("%s/external/sitemap/users/%s", srv.cfg.appviewHost, path)
|
||||
return srv.serveSitemapRequest(c, url, "user subpage")
|
||||
}
|
||||
|
||||
func (srv *Server) serveSitemapRequest(c echo.Context, url, sitemapType string) error {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
slog.Error("failed to construct sitemap request", "err", err, "type", sitemapType)
|
||||
return c.String(http.StatusInternalServerError, "Internal Server Error")
|
||||
}
|
||||
|
||||
resp, err := srv.sitemapClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("failed to send sitemap request to appview", "err", err, "type", sitemapType)
|
||||
return c.String(http.StatusInternalServerError, "Internal Server Error")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("failed to read sitemap error response body", "err", err)
|
||||
}
|
||||
|
||||
slog.Error("invalid sitemap response code",
|
||||
"err", err,
|
||||
"type", sitemapType,
|
||||
"code", resp.StatusCode,
|
||||
"body", string(buf),
|
||||
)
|
||||
return c.String(http.StatusInternalServerError, "Internal Server Error")
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", "application/xml")
|
||||
c.Response().Header().Set("Content-Encoding", "gzip")
|
||||
c.Response().WriteHeader(resp.StatusCode)
|
||||
|
||||
if _, err = io.Copy(c.Response().Writer, resp.Body); err != nil {
|
||||
slog.Error("failed to copy sitemap response body to client", "err", err, "type", sitemapType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,3 +7,5 @@
|
||||
# be ok.
|
||||
User-Agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://bsky.app/sitemap/users.xml.gz
|
||||
|
||||
@@ -37,14 +37,6 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
html {
|
||||
background-color: white;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0px;
|
||||
@@ -59,6 +51,19 @@
|
||||
-ms-overflow-style: scrollbar;
|
||||
font-synthesis-weight: none;
|
||||
}
|
||||
:root {
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: #e2e7ee;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #232e3e;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -67,6 +72,32 @@
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
html.theme--light,
|
||||
html.theme--light body,
|
||||
html.theme--light #root {
|
||||
background-color: white;
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: #DCE2EA;
|
||||
}
|
||||
html.theme--dark,
|
||||
html.theme--dark body,
|
||||
html.theme--dark #root {
|
||||
color-scheme: dark;
|
||||
background-color: black;
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #232E3E;
|
||||
}
|
||||
html.theme--dim,
|
||||
html.theme--dim body,
|
||||
html.theme--dim #root {
|
||||
color-scheme: dark;
|
||||
background-color: #151D28;
|
||||
--text: white;
|
||||
--background: #151D28;
|
||||
--backgroundLight: #2C3A4E;
|
||||
}
|
||||
#splash {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
@@ -93,6 +124,12 @@
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
const theme = localStorage.getItem('ALF_THEME')
|
||||
if (theme) {
|
||||
document.documentElement.classList.add(`theme--${theme}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
{% include "scripts.html" %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ staticCDNHost }}/static/apple-touch-icon.png">
|
||||
|
||||
@@ -108,9 +108,9 @@
|
||||
{% else %}
|
||||
<meta property="og:title" content="@{{ profileHandle }}">
|
||||
{% endif -%}
|
||||
<meta name="description" content="This post requires authentication to view.">
|
||||
<meta property="og:description" content="This post requires authentication to view.">
|
||||
<meta property="twitter:description" content="This post requires authentication to view.">
|
||||
<meta name="description" content="This author has chosen to make their posts visible only to people who are signed in.">
|
||||
<meta property="og:description" content="This author has chosen to make their posts visible only to people who are signed in.">
|
||||
<meta property="twitter:description" content="This author has chosen to make their posts visible only to people who are signed in.">
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif -%}
|
||||
{%- endblock %}
|
||||
@@ -129,7 +129,7 @@
|
||||
<div id="bsky_post_summary">
|
||||
<h3>Post</h3>
|
||||
<p id="bsky_handle">{{ profileHandle }}</p>
|
||||
<p id="bsky_post_text">This post requires authentication to view.</p>
|
||||
<p id="bsky_post_text">This author has chosen to make their posts visible only to people who are signed in.</p>
|
||||
</div>
|
||||
{% endif -%}
|
||||
{%- endblock %}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "yarn install",
|
||||
"run": "yarn web --port $CONDUCTOR_PORT"
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,8 @@ This is NOT required for app development but if you also want to develop the Blu
|
||||
- Start the docker daemon (on MacOS this entails starting the Docker Desktop app)
|
||||
- Launch a Postgres database on port 5432
|
||||
- `cd packages/dev-env && pnpm start`
|
||||
|
||||
Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 yarn start`
|
||||
|
||||
Then, when logging in or creating an account, point it to the localhost port of the devserver.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.112.0",
|
||||
"version": "1.114.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -62,6 +62,7 @@
|
||||
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.js ] || yarn intl:compile",
|
||||
"intl:pull": "crowdin download translations --verbose -b main",
|
||||
"intl:push": "crowdin push translations --verbose -b main",
|
||||
"intl:push-sources": "crowdin push sources --verbose -b main",
|
||||
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
|
||||
"update-extensions": "bash scripts/updateExtensions.sh",
|
||||
"export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps",
|
||||
@@ -72,10 +73,11 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.18.6",
|
||||
"@atproto/api": "^0.18.15",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.5",
|
||||
"@bsky.app/alf": "^0.1.6",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -98,10 +100,9 @@
|
||||
"@miblanchard/react-native-slider": "^2.6.0",
|
||||
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.3.13",
|
||||
"@react-navigation/drawer": "^7.3.12",
|
||||
"@react-navigation/native": "^7.1.9",
|
||||
"@react-navigation/native-stack": "^7.3.13",
|
||||
"@react-navigation/bottom-tabs": "^7.9.0",
|
||||
"@react-navigation/native": "^7.1.26",
|
||||
"@react-navigation/native-stack": "^7.9.0",
|
||||
"@sentry/react-native": "~6.20.0",
|
||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||
"@tanstack/react-query": "5.25.0",
|
||||
@@ -132,46 +133,49 @@
|
||||
"emoji-mart": "^5.5.2",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^54.0.22",
|
||||
"expo-application": "~7.0.7",
|
||||
"expo-blur": "~15.0.7",
|
||||
"expo-build-properties": "~1.0.9",
|
||||
"expo-camera": "~17.0.9",
|
||||
"expo-clipboard": "~8.0.7",
|
||||
"expo-dev-client": "~6.0.16",
|
||||
"expo-device": "~8.0.9",
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-crop-tool": "^0.4.0",
|
||||
"expo-image-manipulator": "~14.0.7",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
"expo-intent-launcher": "~13.0.7",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "~17.0.7",
|
||||
"expo-location": "~19.0.7",
|
||||
"expo-media-library": "~18.2.0",
|
||||
"expo-notifications": "~0.32.12",
|
||||
"expo-screen-orientation": "~9.0.7",
|
||||
"expo-sharing": "~14.0.7",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-task-manager": "~14.0.8",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-video": "~3.0.13",
|
||||
"expo-web-browser": "~15.0.9",
|
||||
"expo": "^54.0.27",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-contacts": "^15.0.10",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.20",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.9",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-linking": "~8.0.10",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-location": "~19.0.8",
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-notifications": "~0.32.14",
|
||||
"expo-privacy-sensitive": "^0.1.0",
|
||||
"expo-screen-orientation": "~9.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-sms": "^14.0.7",
|
||||
"expo-splash-screen": "~31.0.12",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "~14.0.9",
|
||||
"expo-updates": "~29.0.14",
|
||||
"expo-video": "~3.0.15",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
"hls.js": "^1.6.2",
|
||||
"js-sha256": "^0.9.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lande": "^1.0.10",
|
||||
"libphonenumber-js": "^1.12.31",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"multiformats": "9.9.0",
|
||||
@@ -191,7 +195,7 @@
|
||||
"react-native-compressor": "^1.13.0",
|
||||
"react-native-date-picker": "^5.0.13",
|
||||
"react-native-device-attest": "^0.1.6",
|
||||
"react-native-drawer-layout": "^4.1.8",
|
||||
"react-native-drawer-layout": "^4.2.1",
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
@@ -201,7 +205,7 @@
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "^3.19.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-screens": "^4.19.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
@@ -219,10 +223,11 @@
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"tldts": "^6.1.46",
|
||||
"unicode-segmenter": "^0.14.5",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.193",
|
||||
"@atproto/dev-env": "^0.3.204",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
@@ -263,11 +268,10 @@
|
||||
"husky": "^8.0.3",
|
||||
"is-ci": "^3.0.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "^54.0.14",
|
||||
"jest-expo": "~54.0.14",
|
||||
"jest-junit": "^16.0.0",
|
||||
"lint-staged": "^13.2.3",
|
||||
"lockfile-lint": "^4.14.0",
|
||||
"metro-react-native-babel-preset": "^0.77.0",
|
||||
"prettier": "^3.6.0",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"react-refresh": "^0.14.0",
|
||||
@@ -281,10 +285,12 @@
|
||||
"@react-native/babel-preset": "0.81.5",
|
||||
"@react-native/normalize-colors": "0.81.5",
|
||||
"**/@expo/image-utils": "0.8.7",
|
||||
"**/@react-native-async-storage/async-storage": "2.2.0",
|
||||
"**/expo-constants": "18.0.8",
|
||||
"**/expo-device": "7.1.4",
|
||||
"**/zod": "3.23.8",
|
||||
"**/multiformats": "9.9.0"
|
||||
"**/multiformats": "9.9.0",
|
||||
"unicode-segmenter": "0.14.5"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo/ios",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
diff --git a/node_modules/@haileyok/bluesky-video/android/build.gradle b/node_modules/@haileyok/bluesky-video/android/build.gradle
|
||||
index b988d3f..7743421 100644
|
||||
--- a/node_modules/@haileyok/bluesky-video/android/build.gradle
|
||||
+++ b/node_modules/@haileyok/bluesky-video/android/build.gradle
|
||||
@@ -36,6 +36,7 @@ android {
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
+ consumerProguardFiles 'proguard-rules.pro'
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
diff --git a/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
|
||||
new file mode 100644
|
||||
index 0000000..3b5b864
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
|
||||
@@ -0,0 +1,2 @@
|
||||
+# Keep FullscreenActivity from being stripped by R8/ProGuard
|
||||
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
|
||||
diff --git a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
|
||||
index fdabd84..eda8c7c 100644
|
||||
--- a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
|
||||
+++ b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
|
||||
@@ -1,8 +1,11 @@
|
||||
package expo.modules.blueskyvideo
|
||||
|
||||
+import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
+import android.os.Build
|
||||
+import android.util.Log
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.view.ViewGroup
|
||||
@@ -237,9 +240,44 @@ class BlueskyVideoView(
|
||||
// Fullscreen handling
|
||||
|
||||
fun enterFullscreen(keepDisplayOn: Boolean) {
|
||||
- val currentActivity = this.appContext.currentActivity ?: return
|
||||
+ val tag = "BlueskyVideo"
|
||||
+
|
||||
+ Log.d(tag, "enterFullscreen() called - keepDisplayOn=$keepDisplayOn")
|
||||
+ Log.d(tag, " isFullscreen=$isFullscreen, isPlaying=$isPlaying, isMuted=$isMuted")
|
||||
+ Log.d(tag, " player=${player != null}, url=$url")
|
||||
+ Log.d(tag, " isAttachedToWindow=$isAttachedToWindow, isShown=$isShown")
|
||||
+ Log.d(tag, " Android SDK: ${Build.VERSION.SDK_INT}, Device: ${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
+
|
||||
+ val currentActivity = this.appContext.currentActivity
|
||||
+ if (currentActivity == null) {
|
||||
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is null")
|
||||
+ Log.e(tag, " appContext=$appContext")
|
||||
+ onError(mapOf("error" to "Cannot enter fullscreen: no current activity"))
|
||||
+ return
|
||||
+ }
|
||||
+
|
||||
+ Log.d(tag, " currentActivity=$currentActivity")
|
||||
+ Log.d(tag, " activity.isFinishing=${currentActivity.isFinishing}")
|
||||
+ Log.d(tag, " activity.isDestroyed=${currentActivity.isDestroyed}")
|
||||
+ Log.d(tag, " activity.lifecycle=${(currentActivity as? androidx.lifecycle.LifecycleOwner)?.lifecycle?.currentState}")
|
||||
+ Log.d(tag, " activity.hasWindowFocus=${currentActivity.hasWindowFocus()}")
|
||||
+ Log.d(tag, " activity.window.isActive=${currentActivity.window?.isActive}")
|
||||
+
|
||||
+ // Check if activity is in a valid state to start another activity
|
||||
+ if (currentActivity.isFinishing) {
|
||||
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is finishing")
|
||||
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is finishing"))
|
||||
+ return
|
||||
+ }
|
||||
+
|
||||
+ if (currentActivity.isDestroyed) {
|
||||
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is destroyed")
|
||||
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is destroyed"))
|
||||
+ return
|
||||
+ }
|
||||
|
||||
this.enteredFullscreenMuteState = this.isMuted
|
||||
+ Log.d(tag, " saved enteredFullscreenMuteState=$enteredFullscreenMuteState")
|
||||
|
||||
// We always want to start with unmuted state and playing. Fire those from here so the
|
||||
// event dispatcher gets called
|
||||
@@ -247,18 +285,51 @@ class BlueskyVideoView(
|
||||
if (!this.isPlaying) {
|
||||
this.play()
|
||||
}
|
||||
+ Log.d(tag, " after unmute/play: isPlaying=$isPlaying, isMuted=$isMuted")
|
||||
|
||||
// Remove the player from this view, but don't null the player!
|
||||
this.playerView.player = null
|
||||
+ Log.d(tag, " detached player from playerView")
|
||||
|
||||
// create the intent and give it a view
|
||||
val intent = Intent(context, FullscreenActivity::class.java)
|
||||
intent.putExtra("keepDisplayOn", keepDisplayOn)
|
||||
FullscreenActivity.asscVideoView = WeakReference(this)
|
||||
|
||||
+ Log.d(tag, " intent created: $intent")
|
||||
+ Log.d(tag, " intent.component=${intent.component}")
|
||||
+ Log.d(tag, " intent.flags=${intent.flags} (0x${Integer.toHexString(intent.flags)})")
|
||||
+ Log.d(tag, " context for intent=$context")
|
||||
+ Log.d(tag, " FullscreenActivity.asscVideoView set to WeakReference(this)")
|
||||
+
|
||||
// fire the fullscreen event and launch the intent
|
||||
- this.isFullscreen = true
|
||||
- currentActivity.startActivity(intent)
|
||||
+ try {
|
||||
+ Log.d(tag, " calling startActivity()...")
|
||||
+ currentActivity.startActivity(intent)
|
||||
+ this.isFullscreen = true
|
||||
+ Log.d(tag, " startActivity() SUCCESS - isFullscreen set to true")
|
||||
+ } catch (e: Exception) {
|
||||
+ Log.e(tag, "enterFullscreen() FAILED: startActivity() threw exception", e)
|
||||
+ Log.e(tag, " exception class: ${e.javaClass.name}")
|
||||
+ Log.e(tag, " exception message: ${e.message}")
|
||||
+ Log.e(tag, " exception cause: ${e.cause}")
|
||||
+ e.printStackTrace()
|
||||
+
|
||||
+ // Restore state since fullscreen failed
|
||||
+ this.playerView.player = this.player
|
||||
+ Log.d(tag, " restored player to playerView after failure")
|
||||
+
|
||||
+ if (this.enteredFullscreenMuteState) {
|
||||
+ this.mute()
|
||||
+ Log.d(tag, " restored mute state after failure")
|
||||
+ }
|
||||
+
|
||||
+ onError(mapOf(
|
||||
+ "error" to "Failed to enter fullscreen: ${e.message}",
|
||||
+ "exceptionClass" to e.javaClass.name,
|
||||
+ "exceptionMessage" to (e.message ?: "unknown")
|
||||
+ ))
|
||||
+ }
|
||||
}
|
||||
|
||||
fun onExitFullscreen() {
|
||||
@@ -0,0 +1,16 @@
|
||||
diff --git a/node_modules/expo-font/ios/FontLoaderModule.swift b/node_modules/expo-font/ios/FontLoaderModule.swift
|
||||
index 183480f..7b64f6e 100644
|
||||
--- a/node_modules/expo-font/ios/FontLoaderModule.swift
|
||||
+++ b/node_modules/expo-font/ios/FontLoaderModule.swift
|
||||
@@ -2,10 +2,9 @@ import ExpoModulesCore
|
||||
|
||||
public final class FontLoaderModule: Module {
|
||||
// could be a Set, but to be able to pass to JS we keep it as an array
|
||||
- private var registeredFonts: [String]
|
||||
+ private lazy var registeredFonts: [String] = queryCustomNativeFonts()
|
||||
|
||||
public required init(appContext: AppContext) {
|
||||
- self.registeredFonts = queryCustomNativeFonts()
|
||||
super.init(appContext: appContext)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
diff --git a/node_modules/expo-image/build/Image.types.d.ts b/node_modules/expo-image/build/Image.types.d.ts
|
||||
index 022ae48..416504f 100644
|
||||
--- a/node_modules/expo-image/build/Image.types.d.ts
|
||||
+++ b/node_modules/expo-image/build/Image.types.d.ts
|
||||
@@ -152,6 +152,16 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
|
||||
* @default 'normal'
|
||||
*/
|
||||
priority?: 'low' | 'normal' | 'high' | null;
|
||||
+ /**
|
||||
+ * The loading behavior for the image. Maps to the native HTML `loading` attribute on web.
|
||||
+ *
|
||||
+ * - `'lazy'` - Defers loading until the image is near the viewport.
|
||||
+ * - `'eager'` - Loads the image immediately.
|
||||
+ *
|
||||
+ * @default undefined
|
||||
+ * @platform web
|
||||
+ */
|
||||
+ loading?: 'lazy' | 'eager' | null;
|
||||
/**
|
||||
* Determines whether to cache the image and where: on the disk, in the memory or both.
|
||||
*
|
||||
diff --git a/node_modules/expo-image/src/ExpoImage.web.tsx b/node_modules/expo-image/src/ExpoImage.web.tsx
|
||||
index 2a49ff0..1c3de93 100644
|
||||
--- a/node_modules/expo-image/src/ExpoImage.web.tsx
|
||||
+++ b/node_modules/expo-image/src/ExpoImage.web.tsx
|
||||
@@ -70,6 +70,7 @@ export default function ExpoImage({
|
||||
onLoadEnd,
|
||||
onDisplay,
|
||||
priority,
|
||||
+ loading,
|
||||
blurRadius,
|
||||
recyclingKey,
|
||||
style,
|
||||
@@ -118,6 +119,7 @@ export default function ExpoImage({
|
||||
accessibilityLabel={accessibilityLabel ?? alt}
|
||||
cachePolicy={cachePolicy}
|
||||
priority={priority}
|
||||
+ loading={loading}
|
||||
tintColor={tintColor}
|
||||
/>
|
||||
),
|
||||
@@ -149,6 +151,7 @@ export default function ExpoImage({
|
||||
className={className}
|
||||
cachePolicy={cachePolicy}
|
||||
priority={priority}
|
||||
+ loading={loading}
|
||||
contentPosition={selectedSource ? contentPosition : { top: '50%', left: '50%' }}
|
||||
hashPlaceholderContentPosition={contentPosition}
|
||||
hashPlaceholderStyle={imageHashStyle}
|
||||
diff --git a/node_modules/expo-image/src/Image.types.ts b/node_modules/expo-image/src/Image.types.ts
|
||||
index 9dec0e7..61c1621 100644
|
||||
--- a/node_modules/expo-image/src/Image.types.ts
|
||||
+++ b/node_modules/expo-image/src/Image.types.ts
|
||||
@@ -178,6 +178,17 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
|
||||
*/
|
||||
priority?: 'low' | 'normal' | 'high' | null;
|
||||
|
||||
+ /**
|
||||
+ * The loading behavior for the image. Maps to the native HTML `loading` attribute on web.
|
||||
+ *
|
||||
+ * - `'lazy'` - Defers loading until the image is near the viewport.
|
||||
+ * - `'eager'` - Loads the image immediately.
|
||||
+ *
|
||||
+ * @default undefined
|
||||
+ * @platform web
|
||||
+ */
|
||||
+ loading?: 'lazy' | 'eager' | null;
|
||||
+
|
||||
/**
|
||||
* Determines whether to cache the image and where: on the disk, in the memory or both.
|
||||
*
|
||||
diff --git a/node_modules/expo-image/src/web/ImageWrapper.tsx b/node_modules/expo-image/src/web/ImageWrapper.tsx
|
||||
index e8f891d..89a5cb1 100644
|
||||
--- a/node_modules/expo-image/src/web/ImageWrapper.tsx
|
||||
+++ b/node_modules/expo-image/src/web/ImageWrapper.tsx
|
||||
@@ -30,6 +30,7 @@ const ImageWrapper = React.forwardRef(
|
||||
contentPosition,
|
||||
hashPlaceholderContentPosition,
|
||||
priority,
|
||||
+ loading,
|
||||
style,
|
||||
hashPlaceholderStyle,
|
||||
tintColor,
|
||||
@@ -82,6 +83,7 @@ const ImageWrapper = React.forwardRef(
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
fetchPriority={getFetchPriorityFromImagePriority(priority || 'normal')}
|
||||
+ loading={loading || undefined}
|
||||
{...getImageWrapperEventHandler(events, sourceWithHeaders)}
|
||||
{...getImgPropsFromSource(source)}
|
||||
{...props}
|
||||
diff --git a/node_modules/expo-image/src/web/ImageWrapper.types.ts b/node_modules/expo-image/src/web/ImageWrapper.types.ts
|
||||
index 19bbe2f..179837f 100644
|
||||
--- a/node_modules/expo-image/src/web/ImageWrapper.types.ts
|
||||
+++ b/node_modules/expo-image/src/web/ImageWrapper.types.ts
|
||||
@@ -29,6 +29,7 @@ export type ImageWrapperProps = {
|
||||
contentPosition?: ImageContentPositionObject;
|
||||
hashPlaceholderContentPosition?: ImageContentPositionObject;
|
||||
priority?: string | null;
|
||||
+ loading?: 'lazy' | 'eager' | null;
|
||||
style: CSSProperties;
|
||||
tintColor?: string | null;
|
||||
hashPlaceholderStyle?: CSSProperties;
|
||||
@@ -1,24 +1,3 @@
|
||||
diff --git a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
|
||||
index e9a8e3d..3c684e0 100644
|
||||
--- a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
|
||||
+++ b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
|
||||
@@ -296,14 +296,13 @@ class LoaderTask(
|
||||
) {
|
||||
try {
|
||||
val embeddedLoader = EmbeddedLoader(context, configuration, logger, database, directory)
|
||||
- val result = embeddedLoader.load { updateResponse ->
|
||||
+ embeddedLoader.load { _ ->
|
||||
Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true)
|
||||
}
|
||||
- launcher.launch(database)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Unexpected error copying embedded update", e, UpdatesErrorCode.Unknown)
|
||||
- launcher.launch(database)
|
||||
}
|
||||
+ launcher.launch(database)
|
||||
} else {
|
||||
launcher.launch(database)
|
||||
}
|
||||
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
||||
index 68086bd..78c7761 100644
|
||||
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js b/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
|
||||
index efa71f9..a23c624 100644
|
||||
index 40fdcdb..770fd67 100644
|
||||
--- a/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
|
||||
+++ b/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
|
||||
@@ -124,15 +124,21 @@ export function Drawer({
|
||||
@@ -31,7 +31,7 @@ index efa71f9..a23c624 100644
|
||||
- }, finished => runOnJS(onAnimationEnd)(open, finished));
|
||||
+ }, finished => {
|
||||
+ animatingTo.value = null;
|
||||
+ runOnJS(onAnimationEnd)(open, finished)
|
||||
+ runOnJS(onAnimationEnd)(open, finished);
|
||||
+ });
|
||||
if (open) {
|
||||
runOnJS(onOpen)();
|
||||
@@ -0,0 +1,30 @@
|
||||
diff --git a/node_modules/react-native-pager-view/ios/RNCPagerView.m b/node_modules/react-native-pager-view/ios/RNCPagerView.m
|
||||
index adfc7c6..366df60 100644
|
||||
--- a/node_modules/react-native-pager-view/ios/RNCPagerView.m
|
||||
+++ b/node_modules/react-native-pager-view/ios/RNCPagerView.m
|
||||
@@ -498,6 +498,25 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecogni
|
||||
return YES;
|
||||
}
|
||||
|
||||
+ // iOS 26+ full-screen back gesture (interactiveContentPopGestureRecognizer)
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ if (gestureRecognizer == self.panGestureRecognizer &&
|
||||
+ otherGestureRecognizer == self.reactViewController.navigationController.interactiveContentPopGestureRecognizer) {
|
||||
+ UIPanGestureRecognizer* panGestureRecognizer = (UIPanGestureRecognizer*) gestureRecognizer;
|
||||
+ CGPoint velocity = [panGestureRecognizer velocityInView:self];
|
||||
+ BOOL isLTR = [self isLtrLayout];
|
||||
+ BOOL isBackGesture = (isLTR && velocity.x > 0) || (!isLTR && velocity.x < 0);
|
||||
+
|
||||
+ if (self.currentIndex == 0 && isBackGesture) {
|
||||
+ self.scrollView.panGestureRecognizer.enabled = false;
|
||||
+ } else {
|
||||
+ self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
|
||||
+ }
|
||||
+
|
||||
+ return YES;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
|
||||
return NO;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# react-native-pager-view+6.8.0.patch
|
||||
|
||||
Adds support for iOS 26's `interactiveContentPopGestureRecognizer` (full-screen back gesture).
|
||||
|
||||
The pager already handles `RNSPanGestureRecognizer` (react-native-screens' custom full-screen gesture for pre-iOS 26) in `shouldRecognizeSimultaneouslyWithGestureRecognizer:`. It checks if the user is on the leftmost page and swiping right - if so, it disables the scrollview's pan gesture to let the back gesture through.
|
||||
|
||||
This patch adds the same logic for iOS 26's native `interactiveContentPopGestureRecognizer`, so the back gesture works on the leftmost page while the pager still handles swipes on other pages.
|
||||
|
||||
Related issues:
|
||||
- https://github.com/software-mansion/react-native-screens/issues/3512
|
||||
- https://github.com/software-mansion/react-native-screens/pull/3420
|
||||
@@ -1,326 +0,0 @@
|
||||
diff --git a/node_modules/react-native-screens/ios/RNSScreen.mm b/node_modules/react-native-screens/ios/RNSScreen.mm
|
||||
index b62a2e2..cb469db 100644
|
||||
--- a/node_modules/react-native-screens/ios/RNSScreen.mm
|
||||
+++ b/node_modules/react-native-screens/ios/RNSScreen.mm
|
||||
@@ -729,9 +729,26 @@ - (void)notifyTransitionProgress:(double)progress closing:(BOOL)closing goingFor
|
||||
#endif
|
||||
}
|
||||
|
||||
-#if !RCT_NEW_ARCH_ENABLED
|
||||
+- (void)willMoveToWindow:(UIWindow *)newWindow
|
||||
+{
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // In iOS 26, as soon as another screen appears in transition, it is interactable
|
||||
+ // To avoid glitches resulting from clicking buttons mid transition, we temporarily disable all interactions
|
||||
+ // Disabling interactions for parent navigation controller won't be enough in case of nested stack
|
||||
+ // Furthermore, a stack put inside a modal will exist in an entirely different hierarchy
|
||||
+ // To be sure, we block interactions on the whole window.
|
||||
+ // Note that newWindows is nil when moving from instead of moving to, and Obj-C handles nil correctly
|
||||
+ newWindow.userInteractionEnabled = false;
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
- (void)presentationControllerWillDismiss:(UIPresentationController *)presentationController
|
||||
{
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // Disable interactions to disallow multiple modals dismissed at once; see willMoveToWindow
|
||||
+ presentationController.containerView.window.userInteractionEnabled = false;
|
||||
+ }
|
||||
+#if !RCT_NEW_ARCH_ENABLED
|
||||
// On Paper, we need to call both "cancel" and "reset" here because RN's gesture
|
||||
// recognizer does not handle the scenario when it gets cancelled by other top
|
||||
// level gesture recognizer. In this case by the modal dismiss gesture.
|
||||
@@ -744,8 +761,8 @@ - (void)presentationControllerWillDismiss:(UIPresentationController *)presentati
|
||||
// down.
|
||||
[_touchHandler cancel];
|
||||
[_touchHandler reset];
|
||||
-}
|
||||
#endif // !RCT_NEW_ARCH_ENABLED
|
||||
+}
|
||||
|
||||
- (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presentationController
|
||||
{
|
||||
@@ -757,6 +774,10 @@ - (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presenta
|
||||
|
||||
- (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)presentationController
|
||||
{
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // Reenable interactions; see presentationControllerWillDismiss
|
||||
+ presentationController.containerView.window.userInteractionEnabled = true;
|
||||
+ }
|
||||
// NOTE(kkafar): We should consider depracating the use of gesture cancel here & align
|
||||
// with usePreventRemove API of react-navigation v7.
|
||||
[self notifyGestureCancel];
|
||||
@@ -767,6 +788,11 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)pr
|
||||
|
||||
- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController
|
||||
{
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // Reenable interactions; see presentationControllerWillDismiss
|
||||
+ // Dismissed screen doesn't hold a reference to window, but presentingViewController.view does
|
||||
+ presentationController.presentingViewController.view.window.userInteractionEnabled = true;
|
||||
+ }
|
||||
if ([_reactSuperview respondsToSelector:@selector(presentationControllerDidDismiss:)]) {
|
||||
[_reactSuperview performSelector:@selector(presentationControllerDidDismiss:) withObject:presentationController];
|
||||
}
|
||||
@@ -1518,6 +1544,10 @@ - (void)viewWillDisappear:(BOOL)animated
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated
|
||||
{
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // Reenable interactions, see willMoveToWindow
|
||||
+ self.view.window.userInteractionEnabled = true;
|
||||
+ }
|
||||
[super viewDidAppear:animated];
|
||||
if (!_isSwiping || _shouldNotify) {
|
||||
// we are going forward or dismissing without swipe
|
||||
diff --git a/node_modules/react-native-screens/ios/RNSScreenStack.mm b/node_modules/react-native-screens/ios/RNSScreenStack.mm
|
||||
index 229dc58..10b365b 100644
|
||||
--- a/node_modules/react-native-screens/ios/RNSScreenStack.mm
|
||||
+++ b/node_modules/react-native-screens/ios/RNSScreenStack.mm
|
||||
@@ -62,26 +62,6 @@ @interface RNSScreenStackView () <
|
||||
|
||||
@implementation RNSNavigationController
|
||||
|
||||
-#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
|
||||
-- (void)viewDidLoad
|
||||
-{
|
||||
- // iOS 26 introduces new gesture recognizer which replaces our RNSPanGestureRecognizer.
|
||||
- // The problem is that we are not able to handle it here for various reasons:
|
||||
- // - the new recognizer comes with its own delegate and our current approach is to wire
|
||||
- // all recognizers to RNSScreenStackView; to be 100% sure we don't break the logic,
|
||||
- // we would have to decorate its delegate and call it after our code, which would
|
||||
- // break other recognizers that the stack view is the delegate for
|
||||
- // - when RNSScreenStackView.setupGestureHandler method is called, the recognizer hasn't been
|
||||
- // loaded yet and there is no other place to configure in a not "hacky" way
|
||||
- // - the official docs warn us to not use it for anything other than "setting up failure requirements with it"
|
||||
- // - we expose fullScreenGestureEnabled prop to enable/disable the feature,
|
||||
- // so we need control over the delegate
|
||||
- if (@available(iOS 26.0, *)) {
|
||||
- self.interactiveContentPopGestureRecognizer.enabled = NO;
|
||||
- }
|
||||
-}
|
||||
-#endif // iOS 26
|
||||
-
|
||||
#if !TARGET_OS_TV
|
||||
- (UIViewController *)childViewControllerForStatusBarStyle
|
||||
{
|
||||
@@ -219,50 +199,6 @@ - (bool)onRepeatedTabSelectionOfTabScreenController:(RNSTabsScreenViewController
|
||||
return false;
|
||||
}
|
||||
|
||||
-#pragma mark - UINavigationBarDelegate
|
||||
-
|
||||
-#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
|
||||
-- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item
|
||||
-{
|
||||
- if (@available(iOS 26, *)) {
|
||||
- // To prevent popping multiple screens when back button is pressed repeatedly,
|
||||
- // We allow for pop operation to proceed only if no transition is in progress,
|
||||
- // which we check indirectly by checking if transitionCoordinator is set.
|
||||
- // If it's not, we are safe to proceed.
|
||||
- if (self.transitionCoordinator == nil) {
|
||||
- // We still need to disable interactions for back button so click effects are not applied,
|
||||
- // and there is unfortunately no better place for it currently
|
||||
- UIView *button = [navigationBar rnscreens_findBackButtonWrapperView];
|
||||
- if (button != nil) {
|
||||
- button.userInteractionEnabled = false;
|
||||
- }
|
||||
-
|
||||
- return true;
|
||||
- }
|
||||
-
|
||||
- return false;
|
||||
- }
|
||||
-
|
||||
- return true;
|
||||
-}
|
||||
-
|
||||
-- (void)navigationBar:(UINavigationBar *)navigationBar didPopItem:(UINavigationItem *)item
|
||||
-{
|
||||
- if (@available(iOS 26, *)) {
|
||||
- // Reset interactions on back button -> see navigationBar:shouldPopItem
|
||||
- // IMPORTANT: This reset won't execute when preventNativeDismiss is on.
|
||||
- // However, on iOS 26, unlike in previous versions, the back button instance changes
|
||||
- // when handling preventNativeDismiss and userIteractionEnabled is reset.
|
||||
- // The instance also changes when regular screen pop happens, but in that case
|
||||
- // the value of userInteractionEnabled is carried on, and we reset it here.
|
||||
- UIView *button = [navigationBar rnscreens_findBackButtonWrapperView];
|
||||
- if (button != nil) {
|
||||
- button.userInteractionEnabled = true;
|
||||
- }
|
||||
- }
|
||||
-}
|
||||
-#endif // Check for iOS >= 26
|
||||
-
|
||||
#pragma mark - RNSFrameCorrectionProvider
|
||||
|
||||
#ifdef RNS_GAMMA_ENABLED
|
||||
@@ -327,7 +263,7 @@ @implementation RNSScreenStackView {
|
||||
UINavigationController *_controller;
|
||||
NSMutableArray<RNSScreenView *> *_reactSubviews;
|
||||
BOOL _invalidated;
|
||||
- BOOL _isFullWidthSwiping;
|
||||
+ BOOL _isFullWidthSwipingWithPanGesture; // used only for content swipe with RNSPanGestureRecognizer
|
||||
RNSPercentDrivenInteractiveTransition *_interactionController;
|
||||
__weak RNSScreenStackManager *_manager;
|
||||
BOOL _updateScheduled;
|
||||
@@ -522,6 +458,11 @@ - (void)reactAddControllerToClosestParent:(UIViewController *)controller
|
||||
[self addSubview:controller.view];
|
||||
#if !TARGET_OS_TV
|
||||
_controller.interactivePopGestureRecognizer.delegate = self;
|
||||
+ #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ _controller.interactiveContentPopGestureRecognizer.delegate = self;
|
||||
+ }
|
||||
+#endif // Check for iOS >= 26.0
|
||||
#endif
|
||||
[controller didMoveToParentViewController:parentView.reactViewController];
|
||||
// On iOS pre 12 we observed that `willShowViewController` delegate method does not always
|
||||
@@ -943,7 +884,7 @@ - (void)dismissOnReload
|
||||
// when preventing the native dismiss with back button, we have to return the animator.
|
||||
// Also, we need to return the animator when full width swiping even if the animation is not custom,
|
||||
// otherwise the screen will be just popped immediately due to no animation
|
||||
- ((operation == UINavigationControllerOperationPop && shouldCancelDismiss) || _isFullWidthSwiping ||
|
||||
+ ((operation == UINavigationControllerOperationPop && shouldCancelDismiss) || _isFullWidthSwipingWithPanGesture ||
|
||||
[RNSScreenStackAnimator isCustomAnimation:screen.stackAnimation] || _customAnimation)) {
|
||||
return [[RNSScreenStackAnimator alloc] initWithOperation:operation];
|
||||
}
|
||||
@@ -967,23 +908,39 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
|
||||
}
|
||||
RNSScreenView *topScreen = _reactSubviews.lastObject;
|
||||
|
||||
+ BOOL customAnimationOnSwipePropSetAndSelectedAnimationIsCustom =
|
||||
+ topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation];
|
||||
+
|
||||
#if TARGET_OS_TV || TARGET_OS_VISION
|
||||
[self cancelTouchesInParent];
|
||||
return YES;
|
||||
#else
|
||||
- // RNSPanGestureRecognizer will receive events iff topScreen.fullScreenSwipeEnabled == YES;
|
||||
- // Events are filtered in gestureRecognizer:shouldReceivePressOrTouchEvent: method
|
||||
if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
|
||||
- if ([self isInGestureResponseDistance:gestureRecognizer topScreen:topScreen]) {
|
||||
- _isFullWidthSwiping = YES;
|
||||
- [self cancelTouchesInParent];
|
||||
- return YES;
|
||||
+ // On iOS < 26, we have a custom full screen swipe recognizer that functions similarily
|
||||
+ // to interactiveContentPopGestureRecognizer introduced in iOS 26.
|
||||
+ // On iOS >= 26, we want to use the native one, but we are unable to handle custom animations
|
||||
+ // with native interactiveContentPopGestureRecognizer, so we have to fallback to the old implementation.
|
||||
+ // In this case, the old one should behave as close as the new native one, having only the difference
|
||||
+ // in animation, and without any customization that is exclusive for it (e.g. gestureResponseDistance).
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ if (customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) {
|
||||
+ _isFullWidthSwipingWithPanGesture = YES;
|
||||
+ [self cancelTouchesInParent];
|
||||
+ return YES;
|
||||
+ }
|
||||
+ return NO;
|
||||
+ } else {
|
||||
+ if ([self isInGestureResponseDistance:gestureRecognizer topScreen:topScreen]) {
|
||||
+ _isFullWidthSwipingWithPanGesture = YES;
|
||||
+ [self cancelTouchesInParent];
|
||||
+ return YES;
|
||||
+ }
|
||||
+ return NO;
|
||||
}
|
||||
- return NO;
|
||||
}
|
||||
|
||||
// Now we're dealing with RNSScreenEdgeGestureRecognizer (or _UIParallaxTransitionPanGestureRecognizer)
|
||||
- if (topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation]) {
|
||||
+ if (customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) {
|
||||
if ([gestureRecognizer isKindOfClass:[RNSScreenEdgeGestureRecognizer class]]) {
|
||||
UIRectEdge edges = ((RNSScreenEdgeGestureRecognizer *)gestureRecognizer).edges;
|
||||
BOOL isRTL = _controller.view.semanticContentAttribute == UISemanticContentAttributeForceRightToLeft;
|
||||
@@ -1028,7 +985,9 @@ - (void)setupGestureHandlers
|
||||
rightEdgeSwipeGestureRecognizer.delegate = self;
|
||||
[self addGestureRecognizer:rightEdgeSwipeGestureRecognizer];
|
||||
|
||||
- // gesture recognizer for full width swipe gesture
|
||||
+ // Starting from iOS 26, RNSPanGestureRecognizer has been mostly replaced by native
|
||||
+ // interactiveContentPopGestureRecognizer. It still needs to handle custom dismiss animations,
|
||||
+ // which we are not able to handle with the latter.
|
||||
RNSPanGestureRecognizer *panRecognizer = [[RNSPanGestureRecognizer alloc] initWithTarget:self
|
||||
action:@selector(handleSwipe:)];
|
||||
panRecognizer.delegate = self;
|
||||
@@ -1091,7 +1050,7 @@ - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer
|
||||
[_interactionController cancelInteractiveTransition];
|
||||
}
|
||||
_interactionController = nil;
|
||||
- _isFullWidthSwiping = NO;
|
||||
+ _isFullWidthSwipingWithPanGesture = NO;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
@@ -1225,14 +1184,6 @@ - (BOOL)isScrollViewPanGestureRecognizer:(UIGestureRecognizer *)gestureRecognize
|
||||
// Be careful when adding another type of gesture recognizer.
|
||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceivePressOrTouchEvent:(NSObject *)event
|
||||
{
|
||||
- if (@available(iOS 26, *)) {
|
||||
- // in iOS 26, you can swipe to pop screen before the previous one finished transitioning;
|
||||
- // this prevents from registering the second gesture
|
||||
- if ([self isTransitionInProgress]) {
|
||||
- return NO;
|
||||
- }
|
||||
- }
|
||||
-
|
||||
RNSScreenView *topScreen = _reactSubviews.lastObject;
|
||||
|
||||
for (RNSScreenView *s in _reactSubviews.reverseObjectEnumerator) {
|
||||
@@ -1249,10 +1200,30 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceive
|
||||
return NO;
|
||||
}
|
||||
|
||||
+ BOOL customAnimationOnSwipePropSetAndSelectedAnimationIsCustom =
|
||||
+ topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation];
|
||||
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // On iOS 26, fullScreenSwipeEnabled takes no effect, and depending on whether custom animations are on,
|
||||
+ // we select either interactiveContentPopGestureRecognizer or RNSPanGestureRecognizer
|
||||
+ if (([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]] &&
|
||||
+ !customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) ||
|
||||
+ (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer &&
|
||||
+ customAnimationOnSwipePropSetAndSelectedAnimationIsCustom)) {
|
||||
+ return NO;
|
||||
+ }
|
||||
+ } else {
|
||||
+ // We want to pass events to RNSPanGestureRecognizer iff full screen swipe is enabled.
|
||||
+ if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
|
||||
+ return topScreen.fullScreenSwipeEnabled;
|
||||
+ }
|
||||
+ }
|
||||
+#else // check for iOS >= 26
|
||||
// We want to pass events to RNSPanGestureRecognizer iff full screen swipe is enabled.
|
||||
if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
|
||||
return topScreen.fullScreenSwipeEnabled;
|
||||
}
|
||||
+#endif // check for iOS >= 26
|
||||
|
||||
// RNSScreenEdgeGestureRecognizer || _UIParallaxTransitionPanGestureRecognizer
|
||||
return YES;
|
||||
@@ -1268,15 +1239,6 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceive
|
||||
return [self gestureRecognizer:gestureRecognizer shouldReceivePressOrTouchEvent:touch];
|
||||
}
|
||||
|
||||
-- (BOOL)isTransitionInProgress
|
||||
-{
|
||||
- if (_controller.transitionCoordinator != nil) {
|
||||
- return YES;
|
||||
- }
|
||||
-
|
||||
- return NO;
|
||||
-}
|
||||
-
|
||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
|
||||
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
|
||||
{
|
||||
@@ -1289,7 +1251,6 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
|
||||
if (gestureRecognizer.state == UIGestureRecognizerStateBegan || isBackGesture) {
|
||||
return NO;
|
||||
}
|
||||
-
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
@@ -61,7 +61,6 @@ import {Shell} from '#/view/shell'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||
@@ -70,6 +69,10 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
|
||||
import {ToastOutlet} from '#/components/Toast'
|
||||
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||
import {
|
||||
prefetchLiveEvents,
|
||||
Provider as LiveEventsProvider,
|
||||
} from '#/features/liveEvents/context'
|
||||
import * as Geo from '#/geolocation'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
||||
@@ -93,6 +96,7 @@ if (isAndroid) {
|
||||
*/
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -142,50 +146,51 @@ function InnerApp() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
<LiveEventsProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</LiveEventsProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
|
||||
@@ -48,7 +48,6 @@ import {Shell} from '#/view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||
@@ -58,6 +57,10 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
|
||||
import {ToastOutlet} from '#/components/Toast'
|
||||
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||
import {
|
||||
prefetchLiveEvents,
|
||||
Provider as LiveEventsProvider,
|
||||
} from '#/features/liveEvents/context'
|
||||
import * as Geo from '#/geolocation'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
@@ -68,6 +71,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
|
||||
*/
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -118,46 +122,47 @@ function InnerApp() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
<LiveEventsProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</LiveEventsProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
} from '@react-navigation/native'
|
||||
|
||||
import {timeout} from '#/lib/async/timeout'
|
||||
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
||||
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {
|
||||
getNotificationPayload,
|
||||
type NotificationPayload,
|
||||
@@ -45,10 +47,12 @@ import {logger} from '#/logger'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {
|
||||
shouldRequestEmailConfirmation,
|
||||
snoozeEmailConfirmationPrompt,
|
||||
} from '#/state/shell/reminders'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
|
||||
import {DebugModScreen} from '#/view/screens/DebugMod'
|
||||
@@ -64,13 +68,14 @@ import {PostThreadScreen} from '#/view/screens/PostThread'
|
||||
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
|
||||
import {ProfileScreen} from '#/view/screens/Profile'
|
||||
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
|
||||
import {Storybook} from '#/view/screens/Storybook'
|
||||
import {StorybookScreen} from '#/view/screens/Storybook'
|
||||
import {SupportScreen} from '#/view/screens/Support'
|
||||
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
|
||||
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
|
||||
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
|
||||
import {BookmarksScreen} from '#/screens/Bookmarks'
|
||||
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
|
||||
import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {LogScreen} from '#/screens/Log'
|
||||
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||
@@ -102,6 +107,7 @@ 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 {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
|
||||
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
|
||||
import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
|
||||
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
|
||||
@@ -134,10 +140,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>()
|
||||
|
||||
@@ -302,7 +304,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Debug"
|
||||
getComponent={() => Storybook}
|
||||
getComponent={() => StorybookScreen}
|
||||
options={{title: title(msg`Storybook`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -416,6 +418,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="FindContactsSettings"
|
||||
getComponent={() => FindContactsSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Find Contacts`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="NotificationSettings"
|
||||
getComponent={() => NotificationSettingsScreen}
|
||||
@@ -609,6 +619,15 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="FindContactsFlow"
|
||||
getComponent={() => FindContactsFlowScreen}
|
||||
options={{
|
||||
title: title(msg`Find Contacts`),
|
||||
requireAuth: true,
|
||||
gestureEnabled: false,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -617,7 +636,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
* The TabsNavigator is used by native mobile to represent the routes
|
||||
* in 3 distinct tab-stacks with a different root screen on each.
|
||||
*/
|
||||
function TabsNavigator() {
|
||||
function TabsNavigator({
|
||||
layout,
|
||||
}: {
|
||||
layout: React.ComponentProps<typeof Tab.Navigator>['layout']
|
||||
}) {
|
||||
const tabBar = useCallback(
|
||||
(props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
|
||||
<BottomBar {...props} />
|
||||
@@ -630,7 +653,8 @@ function TabsNavigator() {
|
||||
initialRouteName="HomeTab"
|
||||
backBehavior="initialRoute"
|
||||
screenOptions={{headerShown: false, lazy: true}}
|
||||
tabBar={tabBar}>
|
||||
tabBar={tabBar}
|
||||
layout={layout}>
|
||||
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
|
||||
<Tab.Screen
|
||||
@@ -738,7 +762,11 @@ function MessagesTabNavigator() {
|
||||
* The FlatNavigator is used by Web to represent the routes
|
||||
* in a single ("flat") stack.
|
||||
*/
|
||||
const FlatNavigator = () => {
|
||||
const FlatNavigator = ({
|
||||
layout,
|
||||
}: {
|
||||
layout: React.ComponentProps<typeof Flat.Navigator>['layout']
|
||||
}) => {
|
||||
const t = useTheme()
|
||||
const numUnread = useUnreadNotifications()
|
||||
const screenListeners = useWebScrollRestoration()
|
||||
@@ -746,6 +774,7 @@ const FlatNavigator = () => {
|
||||
|
||||
return (
|
||||
<Flat.Navigator
|
||||
layout={layout}
|
||||
screenListeners={screenListeners}
|
||||
screenOptions={screenOptions(t)}>
|
||||
<Flat.Screen
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
useCreateSupportLink,
|
||||
} from '#/lib/hooks/useCreateSupportLink'
|
||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {isAppPassword} from '#/lib/jwt'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
||||
import {useSession, useSessionApi} from '#/state/session'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||
@@ -30,7 +29,7 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/ic
|
||||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Span, Text} from '#/components/Typography'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
@@ -55,9 +54,6 @@ export function NoAccessScreen() {
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const createSupportLink = useCreateSupportLink()
|
||||
|
||||
const {currentAccount} = useSession()
|
||||
const isUsingAppPassword = isAppPassword(currentAccount?.accessJwt || '')
|
||||
|
||||
const aa = useAgeAssurance()
|
||||
const isBlocked = aa.state.status === aa.Status.Blocked
|
||||
const isAARegion = !!region
|
||||
@@ -68,6 +64,13 @@ export function NoAccessScreen() {
|
||||
useEffect(() => {
|
||||
// just counting overall hits here
|
||||
logger.metric(`blockedGeoOverlay:shown`, {})
|
||||
logger.metric(`ageAssurance:noAccessScreen:shown`, {
|
||||
accountCreatedAt: data?.accountCreatedAt || 'unknown',
|
||||
isAARegion,
|
||||
hasDeclaredAge,
|
||||
canUpdateBirthday,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onPressLogout = useCallback(() => {
|
||||
@@ -82,21 +85,38 @@ export function NoAccessScreen() {
|
||||
logoutCurrentAccount('AgeAssuranceNoAccessScreen')
|
||||
}, [logoutCurrentAccount])
|
||||
|
||||
const birthdateUpdateText = canUpdateBirthday ? (
|
||||
<Text style={[textStyles]}>
|
||||
const orgAdmonition = (
|
||||
<Admonition type="tip">
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
style={[textStyles]}
|
||||
{...createStaticClick(() => {
|
||||
birthdateControl.open()
|
||||
})}>
|
||||
clicking here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
For organizational accounts, use the birthdate of the person who is
|
||||
responsible for the account.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Admonition>
|
||||
)
|
||||
|
||||
const birthdateUpdateText = canUpdateBirthday ? (
|
||||
<>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
style={[textStyles]}
|
||||
{...createStaticClick(() => {
|
||||
logger.metric(
|
||||
'ageAssurance:noAccessScreen:openBirthdateDialog',
|
||||
{},
|
||||
)
|
||||
birthdateControl.open()
|
||||
})}>
|
||||
clicking here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{orgAdmonition}
|
||||
</>
|
||||
) : (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
@@ -114,123 +134,127 @@ export function NoAccessScreen() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.w_full,
|
||||
web({
|
||||
maxWidth: 380,
|
||||
paddingTop: gtPhone ? '8vh' : undefined,
|
||||
}),
|
||||
<View style={[a.util_screen_outer, a.flex_1]}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.px_2xl,
|
||||
{
|
||||
gap: 32,
|
||||
paddingTop: isWeb
|
||||
? a.p_5xl.padding
|
||||
: insets.top + a.p_2xl.padding,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.align_start]}>
|
||||
<AgeAssuranceBadge />
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.w_full,
|
||||
web({
|
||||
maxWidth: 380,
|
||||
paddingTop: gtPhone ? '8vh' : undefined,
|
||||
}),
|
||||
{
|
||||
gap: 32,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.align_start]}>
|
||||
<AgeAssuranceBadge />
|
||||
</View>
|
||||
|
||||
{hasDeclaredAge ? (
|
||||
<>
|
||||
{isAARegion ? (
|
||||
<>
|
||||
{hasDeclaredAge ? (
|
||||
<>
|
||||
{isAARegion ? (
|
||||
<>
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hey there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
You are accessing Bluesky from a region that legally
|
||||
requires us to verify your age before allowing you to
|
||||
access the app.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{!aa.flags.isOverRegionMinAccessAge && (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, your declared age indicates that you
|
||||
are not old enough to access Bluesky in your region.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isBlocked && birthdateUpdateText}
|
||||
</View>
|
||||
|
||||
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hey there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
You are accessing Bluesky from a region that legally
|
||||
requires us to verify your age before allowing you to
|
||||
access the app.
|
||||
Unfortunately, the birthdate you have saved to your
|
||||
profile makes you too young to access Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{!isBlocked && birthdateUpdateText}
|
||||
{birthdateUpdateText}
|
||||
</View>
|
||||
|
||||
<AccessSection />
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, the birthdate you have saved to your
|
||||
profile makes you too young to access Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{birthdateUpdateText}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hi there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
In order to provide an age-appropriate experience, we need to
|
||||
know your birthdate. This is a one-time thing, and your data
|
||||
will be kept private.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Set your birthdate below and we'll get you back to posting and
|
||||
exploring in no time!
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
onPress={() => birthdateControl.open()}>
|
||||
<ButtonText>
|
||||
<Trans>Add your birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{isUsingAppPassword && (
|
||||
<Admonition type="info">
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hi there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Hmm, it looks like you're logged in with an{' '}
|
||||
<Span style={[a.italic]}>App Password</Span>. To set your
|
||||
birthdate, you'll need to log in with your main account
|
||||
password, or ask whomever controls this account to do so.
|
||||
In order to provide an age-appropriate experience, we need
|
||||
to know your birthdate. This is a one-time thing, and your
|
||||
data will be kept private.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Set your birthdate below and we'll get you back to posting
|
||||
and exploring in no time!
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
onPress={() => birthdateControl.open()}>
|
||||
<ButtonText>
|
||||
<Trans>Add your birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<View style={[a.pt_lg, a.gap_xl]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
To log out,{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to log out`)}
|
||||
{...createStaticClick(() => {
|
||||
onPressLogout()
|
||||
})}>
|
||||
click here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
{orgAdmonition}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.pt_lg, a.gap_xl]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
To log out,{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to log out`)}
|
||||
{...createStaticClick(() => {
|
||||
onPressLogout()
|
||||
})}>
|
||||
click here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<BirthDateSettingsDialog control={birthdateControl} />
|
||||
|
||||
|
||||
@@ -136,6 +136,15 @@ export async function prefetchConfig() {
|
||||
}
|
||||
})
|
||||
}
|
||||
export async function refetchConfig() {
|
||||
logger.debug(`refetchConfig: fetching...`)
|
||||
const res = await getConfig()
|
||||
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
||||
configQueryKey,
|
||||
res,
|
||||
)
|
||||
return res
|
||||
}
|
||||
export function useConfigQuery() {
|
||||
return useQuery(
|
||||
{
|
||||
@@ -146,6 +155,10 @@ export function useConfigQuery() {
|
||||
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
|
||||
*/
|
||||
staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
|
||||
/**
|
||||
* N.B. if prefetch failed above, we'll have no `initialData`, and this
|
||||
* query will run on startup.
|
||||
*/
|
||||
initialData: getConfigFromCache(),
|
||||
initialDataUpdatedAt: () =>
|
||||
qc.getQueryState(configQueryKey)?.dataUpdatedAt,
|
||||
|
||||
@@ -17,18 +17,32 @@ export const geolocation: Geolocation | undefined = enabled
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const deviceGeolocation: Geolocation | undefined = enabled
|
||||
? {
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
: undefined
|
||||
const deviceGeolocationEnabled = false || IS_E2E
|
||||
export const deviceGeolocation: Geolocation | undefined =
|
||||
enabled && deviceGeolocationEnabled
|
||||
? {
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
regions: [
|
||||
{
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'BB',
|
||||
regionCode: undefined,
|
||||
minAccessAge: 16,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.Default,
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
type AgeAssuranceState,
|
||||
AgeAssuranceStatus,
|
||||
} from '#/ageAssurance/types'
|
||||
import {isUserUnderAdultAge} from '#/ageAssurance/util'
|
||||
import {
|
||||
isUnderAge,
|
||||
MIN_ACCESS_AGE,
|
||||
useAgeAssuranceRegionConfigWithFallback,
|
||||
} from '#/ageAssurance/util'
|
||||
|
||||
export {
|
||||
prefetchConfig as prefetchAgeAssuranceConfig,
|
||||
@@ -24,6 +28,7 @@ export {
|
||||
usePatchServerState as usePatchAgeAssuranceServerState,
|
||||
} from '#/ageAssurance/data'
|
||||
export {logger} from '#/ageAssurance/logger'
|
||||
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
|
||||
|
||||
const AgeAssuranceStateContext = createContext<{
|
||||
Access: typeof AgeAssuranceAccess
|
||||
@@ -32,6 +37,8 @@ const AgeAssuranceStateContext = createContext<{
|
||||
flags: {
|
||||
adultContentDisabled: boolean
|
||||
chatDisabled: boolean
|
||||
isOverRegionMinAccessAge: boolean
|
||||
isOverAppMinAccessAge: boolean
|
||||
}
|
||||
}>({
|
||||
Access: AgeAssuranceAccess,
|
||||
@@ -44,6 +51,8 @@ const AgeAssuranceStateContext = createContext<{
|
||||
flags: {
|
||||
adultContentDisabled: false,
|
||||
chatDisabled: false,
|
||||
isOverRegionMinAccessAge: false,
|
||||
isOverAppMinAccessAge: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -69,6 +78,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
const state = useAgeAssuranceState()
|
||||
const {data} = useAgeAssuranceDataContext()
|
||||
const config = useAgeAssuranceRegionConfigWithFallback()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
|
||||
const handleAccessUpdate = useCallback(
|
||||
@@ -89,11 +99,17 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
<AgeAssuranceStateContext.Provider
|
||||
value={useMemo(() => {
|
||||
const chatDisabled = state.access !== AgeAssuranceAccess.Full
|
||||
const isUnderage = data?.birthdate
|
||||
? isUserUnderAdultAge(data.birthdate)
|
||||
const isUnderAdultAge = data?.birthdate
|
||||
? isUnderAge(data.birthdate, 18)
|
||||
: true
|
||||
const isOverRegionMinAccessAge = data?.birthdate
|
||||
? !isUnderAge(data.birthdate, config.minAccessAge)
|
||||
: false
|
||||
const isOverAppMinAccessAge = data?.birthdate
|
||||
? !isUnderAge(data.birthdate, MIN_ACCESS_AGE)
|
||||
: false
|
||||
const adultContentDisabled =
|
||||
state.access !== AgeAssuranceAccess.Full || isUnderage
|
||||
state.access !== AgeAssuranceAccess.Full || isUnderAdultAge
|
||||
return {
|
||||
Access: AgeAssuranceAccess,
|
||||
Status: AgeAssuranceStatus,
|
||||
@@ -101,9 +117,11 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
flags: {
|
||||
adultContentDisabled,
|
||||
chatDisabled,
|
||||
isOverRegionMinAccessAge,
|
||||
isOverAppMinAccessAge,
|
||||
},
|
||||
}
|
||||
}, [state, data])}>
|
||||
}, [state, data, config])}>
|
||||
{children}
|
||||
</AgeAssuranceStateContext.Provider>
|
||||
)
|
||||
|
||||
@@ -30,12 +30,19 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
access: AgeAssuranceAccess.Safe,
|
||||
}
|
||||
|
||||
// should never happen, but need to guard
|
||||
/**
|
||||
* This can happen if the prefetch fails (such as due to network issues).
|
||||
* The query handler will try it again, but if it continues to fail, of
|
||||
* course we won't have config.
|
||||
*
|
||||
* In this case, fail open to avoid blocking users.
|
||||
*/
|
||||
if (!config) {
|
||||
logger.warn('useAgeAssuranceState: missing config')
|
||||
return {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Unknown,
|
||||
access: AgeAssuranceAccess.Safe,
|
||||
error: 'config',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export type AgeAssuranceState = {
|
||||
lastInitiatedAt?: string
|
||||
status: AgeAssuranceStatus
|
||||
access: AgeAssuranceAccess
|
||||
error?: 'config' // maybe other specific cases in the future
|
||||
}
|
||||
|
||||
export function parseStatusFromString(raw: string) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
@@ -8,9 +9,9 @@ import {
|
||||
PUBLIC_APPVIEW_DID,
|
||||
} from '#/lib/constants'
|
||||
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {BLUESKY_PROXY_DID} from '#/env'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
|
||||
@@ -29,8 +30,8 @@ export function useBeginAgeAssurance() {
|
||||
'countryCode' | 'regionCode'
|
||||
>,
|
||||
) {
|
||||
const countryCode = geolocation?.countryCode
|
||||
const regionCode = geolocation?.regionCode
|
||||
const countryCode = geolocation?.countryCode?.toUpperCase()
|
||||
const regionCode = geolocation?.regionCode?.toUpperCase()
|
||||
if (!countryCode) {
|
||||
throw new Error(`Geolocation not available, cannot init age assurance.`)
|
||||
}
|
||||
@@ -47,6 +48,16 @@ export function useBeginAgeAssurance() {
|
||||
appView.sessionManager.session.accessJwt = token
|
||||
appView.sessionManager.session.refreshJwt = ''
|
||||
|
||||
logger.metric(
|
||||
'ageAssurance:api:begin',
|
||||
{
|
||||
platform: Platform.OS,
|
||||
countryCode,
|
||||
regionCode,
|
||||
},
|
||||
{statsig: false},
|
||||
)
|
||||
|
||||
/*
|
||||
* 2s wait is good actually. Email sending takes a hot sec and this helps
|
||||
* ensure the email is ready for the user once they open their inbox.
|
||||
@@ -55,8 +66,8 @@ export function useBeginAgeAssurance() {
|
||||
2e3,
|
||||
appView.app.bsky.ageassurance.begin({
|
||||
...props,
|
||||
countryCode: countryCode.toUpperCase(),
|
||||
regionCode: regionCode ? regionCode.toUpperCase() : undefined,
|
||||
countryCode,
|
||||
regionCode,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,14 +3,32 @@ import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
getAgeAssuranceRegionConfig,
|
||||
type ModerationPrefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
|
||||
const DEFAULT_MIN_AGE = 13
|
||||
export const MIN_ACCESS_AGE = 13
|
||||
const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
|
||||
countryCode: '*',
|
||||
regionCode: undefined,
|
||||
minAccessAge: MIN_ACCESS_AGE,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
age: MIN_ACCESS_AGE,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: AgeAssuranceAccess.None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age assurance region config based on geolocation, with fallback to
|
||||
@@ -28,23 +46,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
|
||||
return (
|
||||
region || {
|
||||
countryCode: '*',
|
||||
regionCode: undefined,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
age: DEFAULT_MIN_AGE,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: AgeAssuranceAccess.None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
return region || FALLBACK_REGION_CONFIG
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +67,14 @@ export function useAgeAssuranceRegionConfig() {
|
||||
}, [config, geolocation])
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the age assurance region config based on current geolocation.
|
||||
* Falls back to our app defaults if no region config is found.
|
||||
*/
|
||||
export function useAgeAssuranceRegionConfigWithFallback() {
|
||||
return useAgeAssuranceRegionConfig() || FALLBACK_REGION_CONFIG
|
||||
}
|
||||
|
||||
/**
|
||||
* Some users may have erroneously set their birth date to the current date
|
||||
* if one wasn't set on their account. We previously didn't do validation on
|
||||
@@ -76,15 +86,11 @@ export function isLegacyBirthdateBug(birthDate: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the user is under the minimum age required to use the app.
|
||||
* This applies to all regions.
|
||||
* Returns whether the date (converted to an age as a whole integer) is under
|
||||
* the provided minimum age.
|
||||
*/
|
||||
export function isUserUnderMinimumAge(birthDate: string) {
|
||||
return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE
|
||||
}
|
||||
|
||||
export function isUserUnderAdultAge(birthDate: string) {
|
||||
return getAge(new Date(birthDate)) < 18
|
||||
export function isUnderAge(birthDate: string, age: number) {
|
||||
return getAge(new Date(birthDate)) < age
|
||||
}
|
||||
|
||||
export function getBirthdateStringFromAge(age: number) {
|
||||
@@ -95,3 +101,11 @@ export function getBirthdateStringFromAge(age: number) {
|
||||
today.getDate() - 1, // set to day before to ensure age is reached
|
||||
).toISOString()
|
||||
}
|
||||
|
||||
export const makeAgeRestrictedModerationPrefs = (
|
||||
prefs: ModerationPrefs,
|
||||
): ModerationPrefs => ({
|
||||
...prefs,
|
||||
adultContentEnabled: false,
|
||||
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
||||
})
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
import {themes} from '#/alf/themes'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
|
||||
export {
|
||||
type TextStyleProp,
|
||||
type Theme,
|
||||
utils,
|
||||
type ViewStyleProp,
|
||||
} from '@bsky.app/alf'
|
||||
export {atoms} from '#/alf/atoms'
|
||||
export * from '#/alf/breakpoints'
|
||||
export * from '#/alf/fonts'
|
||||
|
||||
@@ -51,6 +51,7 @@ function updateDocument(theme: ThemeName) {
|
||||
html.classList.add(`theme--${theme}`)
|
||||
// set color to 'theme-color' meta tag
|
||||
meta?.setAttribute('content', getBackgroundColor(theme))
|
||||
window.localStorage.setItem('ALF_THEME', theme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -359,7 +359,13 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Handle({difference = false}: {difference?: boolean}) {
|
||||
export function Handle({
|
||||
difference = false,
|
||||
fill,
|
||||
}: {
|
||||
difference?: boolean
|
||||
fill?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {screenReaderEnabled} = useA11y()
|
||||
@@ -390,7 +396,7 @@ export function Handle({difference = false}: {difference?: boolean}) {
|
||||
opacity: 0.75,
|
||||
}
|
||||
: {
|
||||
backgroundColor: t.palette.contrast_975,
|
||||
backgroundColor: fill || t.palette.contrast_975,
|
||||
opacity: 0.5,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, {useMemo} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
@@ -21,19 +21,21 @@ import {
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
type ButtonProps,
|
||||
ButtonText,
|
||||
} from '#/components/Button'
|
||||
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
||||
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
||||
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText, type RichTextProps} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
|
||||
|
||||
@@ -49,7 +51,11 @@ export function Default(props: Props) {
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar src={view.avatar} />
|
||||
<TitleAndByline title={view.displayName} creator={view.creator} />
|
||||
<TitleAndByline
|
||||
title={view.displayName}
|
||||
creator={view.creator}
|
||||
uri={view.uri}
|
||||
/>
|
||||
<SaveButton view={view} pin />
|
||||
</Header>
|
||||
<Description description={view.description} />
|
||||
@@ -118,14 +124,40 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
|
||||
export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
uri,
|
||||
}: {
|
||||
title: string
|
||||
creator?: bsky.profile.AnyProfileView
|
||||
uri?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const activeLiveEvents = useActiveLiveEventFeedUris()
|
||||
const liveColor = useMemo(
|
||||
() =>
|
||||
select(t.name, {
|
||||
dark: t.palette.negative_600,
|
||||
dim: t.palette.negative_600,
|
||||
light: t.palette.negative_500,
|
||||
}),
|
||||
[t],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
{uri && activeLiveEvents.has(uri) && (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
|
||||
<LiveIcon size="xs" fill={liveColor} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_2xs,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
{color: liveColor},
|
||||
]}>
|
||||
<Trans>Happening now</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, {useCallback, useEffect, useRef} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {type MetricEvents} from '#/logger/metrics'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
@@ -14,7 +15,10 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
import {
|
||||
useSuggestedFollowsByActorQuery,
|
||||
useSuggestedFollowsQuery,
|
||||
} from '#/state/queries/suggested-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
import {type SeenPost} from '#/state/userActionHistory'
|
||||
@@ -31,6 +35,7 @@ import {useDialogControl} from '#/components/Dialog'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -38,6 +43,8 @@ import type * as bsky from '#/types/bsky'
|
||||
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
|
||||
import {ProgressGuideList} from './ProgressGuide/List'
|
||||
|
||||
const DISMISS_ANIMATION_DURATION = 200
|
||||
|
||||
const MOBILE_CARD_WIDTH = 165
|
||||
const FINAL_CARD_WIDTH = 120
|
||||
|
||||
@@ -49,6 +56,7 @@ function CardOuter({
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<View
|
||||
testID="CardOuter"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
@@ -202,6 +210,9 @@ export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||
}
|
||||
|
||||
export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const maxLength = gtMobile ? 4 : 6
|
||||
const {
|
||||
isLoading: isSuggestionsLoading,
|
||||
data,
|
||||
@@ -209,29 +220,194 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
} = useSuggestedFollowsByActorQuery({
|
||||
did,
|
||||
})
|
||||
const {
|
||||
data: moreSuggestions,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = React.useCallback((dismissedDid: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(dismissedDid))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(dismissedDid))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(dismissedDid)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from the actor-specific query with fallback suggestions
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const actorProfiles = data?.suggestions ?? []
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring actor-specific profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: bsky.profile.AnyProfileView[] = []
|
||||
|
||||
for (const profile of actorProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.did) && profile.did !== did) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [data?.suggestions, moreSuggestions?.pages, did])
|
||||
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
maxLength,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
moderationOpts,
|
||||
])
|
||||
|
||||
return (
|
||||
<ProfileGrid
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={data?.suggestions ?? []}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
recId={data?.recId}
|
||||
error={error}
|
||||
viewContext="profile"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFollowsHome() {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const maxLength = gtMobile ? 4 : 6
|
||||
const {
|
||||
isLoading: isSuggestionsLoading,
|
||||
profiles,
|
||||
error,
|
||||
profiles: experimentalProfiles,
|
||||
error: experimentalError,
|
||||
} = useExperimentalSuggestedUsersQuery()
|
||||
const {
|
||||
data: moreSuggestions,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
error: suggestionsError,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = React.useCallback((did: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(did))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(did)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from experimental query with paginated suggestions
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring experimental profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: bsky.profile.AnyProfileView[] = []
|
||||
|
||||
for (const profile of experimentalProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [experimentalProfiles, moreSuggestions?.pages])
|
||||
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
maxLength,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
moderationOpts,
|
||||
])
|
||||
|
||||
return (
|
||||
<ProfileGrid
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={profiles}
|
||||
error={error}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
error={experimentalError || suggestionsError}
|
||||
viewContext="feed"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -240,19 +416,26 @@ export function ProfileGrid({
|
||||
isSuggestionsLoading,
|
||||
error,
|
||||
profiles,
|
||||
totalProfileCount,
|
||||
recId,
|
||||
viewContext = 'feed',
|
||||
onDismiss,
|
||||
dismissingDids,
|
||||
isVisible = true,
|
||||
}: {
|
||||
isSuggestionsLoading: boolean
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
totalProfileCount?: number
|
||||
recId?: number
|
||||
error: Error | null
|
||||
dismissingDids?: Set<string>
|
||||
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||
onDismiss?: (did: string) => void
|
||||
isVisible?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const followDialogControl = useDialogControl()
|
||||
@@ -260,6 +443,7 @@ export function ProfileGrid({
|
||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||
const isProfileHeaderContext = viewContext === 'profileHeader'
|
||||
const isFeedContext = viewContext === 'feed'
|
||||
const showDismissButton = onDismiss && gate('suggested_users_dismiss')
|
||||
|
||||
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
|
||||
const minLength = gtMobile ? 3 : 4
|
||||
@@ -363,20 +547,9 @@ export function ProfileGrid({
|
||||
: error || !profiles.length
|
||||
? null
|
||||
: profiles.slice(0, maxLength).map((profile, index) => (
|
||||
<ProfileCard.Link
|
||||
<Animated.View
|
||||
key={profile.did}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logEvent('suggestedUser:press', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
@@ -385,68 +558,128 @@ export function ProfileGrid({
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
{
|
||||
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
|
||||
transitionProperty: 'opacity',
|
||||
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
|
||||
},
|
||||
]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
||||
<ProfileCard.Outer>
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pb_sm,
|
||||
a.mb_auto,
|
||||
]}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
size={88}
|
||||
/>
|
||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||
<ProfileCard.Name
|
||||
<ProfileCard.Link
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logEvent('suggestedUser:press', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
style={[a.flex_1]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<ProfileCard.Outer>
|
||||
{showDismissButton && (
|
||||
<Button
|
||||
label={_(msg`Dismiss this suggestion`)}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
onDismiss!(profile.did)
|
||||
logEvent('suggestedUser:dismiss', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
recId,
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_10,
|
||||
a.p_xs,
|
||||
{top: -4, right: -4},
|
||||
]}>
|
||||
{({
|
||||
hovered: dismissHovered,
|
||||
pressed: dismissPressed,
|
||||
}) => (
|
||||
<X
|
||||
size="xs"
|
||||
fill={
|
||||
dismissHovered || dismissPressed
|
||||
? t.atoms.text.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pb_sm,
|
||||
a.mb_auto,
|
||||
]}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
size={88}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_center,
|
||||
a.text_xs,
|
||||
]}
|
||||
/>
|
||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_center,
|
||||
a.text_xs,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
style={[a.rounded_sm]}
|
||||
onFollow={() => {
|
||||
logEvent('suggestedUser:follow', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
location: 'Card',
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
style={[a.rounded_sm]}
|
||||
onFollow={() => {
|
||||
logEvent('suggestedUser:follow', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
location: 'Card',
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
</Animated.View>
|
||||
))
|
||||
|
||||
if (error || (!isLoading && profiles.length < minLength)) {
|
||||
// Use totalProfileCount (before dismissals) for minLength check on initial render.
|
||||
const profileCountForMinCheck = totalProfileCount ?? profiles.length
|
||||
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
|
||||
logger.debug(`Not enough profiles to show suggested follows`)
|
||||
return null
|
||||
}
|
||||
@@ -539,7 +772,6 @@ export function ProfileGrid({
|
||||
}
|
||||
|
||||
function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
@@ -553,7 +785,6 @@ function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
|
||||
a.gap_sm,
|
||||
a.p_md,
|
||||
a.rounded_lg,
|
||||
t.atoms.shadow_sm,
|
||||
{width: FINAL_CARD_WIDTH},
|
||||
]}>
|
||||
<ButtonIcon icon={ArrowRight} size="lg" />
|
||||
@@ -611,6 +842,7 @@ export function SuggestedFeeds() {
|
||||
<FeedCard.TitleAndByline
|
||||
title={feed.displayName}
|
||||
creator={feed.creator}
|
||||
uri={feed.uri}
|
||||
/>
|
||||
</FeedCard.Header>
|
||||
<FeedCard.Description
|
||||
@@ -702,8 +934,15 @@ export function SuggestedFeeds() {
|
||||
|
||||
export function ProgressGuide() {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
!gtMobile && {marginTop: 4},
|
||||
]}>
|
||||
<ProgressGuideList />
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {Fragment, useMemo} from 'react'
|
||||
import {Text as RNText} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
type CountryCode,
|
||||
getDefaultCountry,
|
||||
INTERNATIONAL_TELEPHONE_CODES,
|
||||
} from '#/lib/international-telephone-codes'
|
||||
@@ -23,8 +25,8 @@ export function InternationalPhoneCodeSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
value?: CountryCode
|
||||
onChange: (value: CountryCode) => void
|
||||
}) {
|
||||
const {_, i18n} = useLingui()
|
||||
const location = useGeolocation()
|
||||
@@ -62,7 +64,7 @@ export function InternationalPhoneCodeSelect({
|
||||
}, [value, items])
|
||||
|
||||
return (
|
||||
<Select.Root value={value} onValueChange={onChange}>
|
||||
<Select.Root value={value} onValueChange={onChange as (v: string) => void}>
|
||||
<Select.Trigger label={_(msg`Select telephone code`)}>
|
||||
<Select.ValueText placeholder="+..." webOverrideValue={selected}>
|
||||
{selected => (
|
||||
@@ -112,5 +114,5 @@ function Flag({unicodeFlag, svgFlag}: {unicodeFlag: string; svgFlag: any}) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
return unicodeFlag + ' '
|
||||
return <RNText style={[{lineHeight: 21}]}>{unicodeFlag + ' '}</RNText>
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Outer({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
noBottomBorder?: boolean
|
||||
headerRef?: React.MutableRefObject<View | null>
|
||||
headerRef?: React.RefObject<View | null>
|
||||
sticky?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
@@ -129,6 +129,7 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={onPressBack}
|
||||
hitSlop={HITSLOP_30}
|
||||
style={[
|
||||
@@ -183,7 +184,7 @@ export function TitleText({
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
isIOS && align === 'platform' && a.text_center,
|
||||
gtMobile && a.text_xl,
|
||||
|
||||
@@ -167,6 +167,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
@@ -193,7 +194,6 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
{paddingTop: 3},
|
||||
style,
|
||||
disabled && t.atoms.text_contrast_low,
|
||||
]}>
|
||||
@@ -202,16 +202,18 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||
export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = useMenuItemContext()
|
||||
return (
|
||||
<Comp
|
||||
size="lg"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
fill
|
||||
? fill({disabled})
|
||||
: disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -262,6 +262,7 @@ export function Item({children, label, onPress, style, ...rest}: ItemProps) {
|
||||
a.gap_lg,
|
||||
a.py_sm,
|
||||
a.rounded_xs,
|
||||
a.overflow_hidden,
|
||||
{minHeight: 32, paddingHorizontal: 10},
|
||||
web({outline: 0}),
|
||||
(hovered || focused) &&
|
||||
@@ -302,7 +303,7 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
export function ItemIcon({icon: Comp, position = 'left', fill}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = useMenuItemContext()
|
||||
return (
|
||||
@@ -319,9 +320,11 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
<Comp
|
||||
size="md"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
fill
|
||||
? fill({disabled})
|
||||
: disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -107,6 +107,7 @@ export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
|
||||
export type ItemIconProps = React.PropsWithChildren<{
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
position?: 'left' | 'right'
|
||||
fill?: (props: {disabled: boolean}) => string
|
||||
}>
|
||||
|
||||
export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
|
||||
|
||||
@@ -226,6 +226,7 @@ export function ExternalPlayer({
|
||||
style={[a.flex_1]}
|
||||
source={{uri: link.thumb}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
/>
|
||||
<Fill
|
||||
style={[
|
||||
|
||||
@@ -11,6 +11,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {type EmbedPlayerParams} from '#/lib/strings/embed-player'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useAutoplayDisabled} from '#/state/preferences'
|
||||
@@ -111,6 +112,15 @@ export function GifEmbed({
|
||||
playerRef.current?.toggleAsync()
|
||||
}, [])
|
||||
|
||||
let aspectRatio = 1
|
||||
if (params.dimensions) {
|
||||
aspectRatio = clamp(
|
||||
params.dimensions.width / params.dimensions.height,
|
||||
0.75,
|
||||
4,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -118,7 +128,8 @@ export function GifEmbed({
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
{aspectRatio: params.dimensions!.width / params.dimensions!.height},
|
||||
{backgroundColor: t.palette.black},
|
||||
{aspectRatio},
|
||||
style,
|
||||
]}>
|
||||
<View
|
||||
|
||||
@@ -100,6 +100,7 @@ export const ExternalEmbed = ({
|
||||
style={[a.aspect_card]}
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
|
||||
@@ -17,16 +17,16 @@ export function FeedEmbed({
|
||||
return (
|
||||
<FeedCard.Link
|
||||
view={embed.view}
|
||||
style={[a.border, t.atoms.border_contrast_low, a.p_md, a.rounded_sm]}>
|
||||
style={[a.border, t.atoms.border_contrast_low, a.p_sm, a.rounded_md]}>
|
||||
<FeedCard.Outer>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.Avatar src={embed.view.avatar} />
|
||||
<FeedCard.Avatar src={embed.view.avatar} size={48} />
|
||||
<FeedCard.TitleAndByline
|
||||
title={embed.view.displayName}
|
||||
creator={embed.view.creator}
|
||||
uri={embed.view.uri}
|
||||
/>
|
||||
</FeedCard.Header>
|
||||
<FeedCard.Likes count={embed.view.likeCount || 0} />
|
||||
</FeedCard.Outer>
|
||||
</FeedCard.Link>
|
||||
)
|
||||
|
||||
@@ -10,9 +10,9 @@ import {Image} from 'expo-image'
|
||||
|
||||
import {useLightboxControls} from '#/state/lightbox'
|
||||
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
|
||||
import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {type EmbedType} from '#/types/bsky/post'
|
||||
import {type CommonProps} from './types'
|
||||
|
||||
@@ -4,7 +4,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotate'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text as TypoText} from '#/components/Typography'
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
@@ -13,10 +13,10 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isFirefox} from '#/lib/browser'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {
|
||||
HLSUnsupportedError,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type $Typed,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||
@@ -19,6 +18,7 @@ import {useSession} from '#/state/session'
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -232,7 +232,7 @@ export function QuoteEmbed({
|
||||
viewContext?: QuoteEmbedViewContext
|
||||
}) {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const quote = React.useMemo<$Typed<AppBskyFeedDefs.PostView>>(
|
||||
const quote = useMemo<$Typed<AppBskyFeedDefs.PostView>>(
|
||||
() => ({
|
||||
...embed.view,
|
||||
$type: 'app.bsky.feed.defs#postView',
|
||||
@@ -241,18 +241,17 @@ export function QuoteEmbed({
|
||||
}),
|
||||
[embed],
|
||||
)
|
||||
const moderation = React.useMemo(() => {
|
||||
const moderation = useMemo(() => {
|
||||
return moderationOpts ? moderatePost(quote, moderationOpts) : undefined
|
||||
}, [quote, moderationOpts])
|
||||
|
||||
const t = useTheme()
|
||||
const queryClient = useQueryClient()
|
||||
const pal = usePalette('default')
|
||||
const itemUrip = new AtUri(quote.uri)
|
||||
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
|
||||
const itemTitle = `Post by ${quote.author.handle}`
|
||||
|
||||
const richText = React.useMemo(() => {
|
||||
const richText = useMemo(() => {
|
||||
if (
|
||||
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
quote.record,
|
||||
@@ -266,17 +265,26 @@ export function QuoteEmbed({
|
||||
: undefined
|
||||
}, [quote.record])
|
||||
|
||||
const onBeforePress = React.useCallback(() => {
|
||||
const onBeforePress = useCallback(() => {
|
||||
unstableCacheProfileView(queryClient, quote.author)
|
||||
onOpen?.()
|
||||
}, [queryClient, quote.author, onOpen])
|
||||
|
||||
const [hover, setHover] = React.useState(false)
|
||||
const {
|
||||
state: hover,
|
||||
onIn: onPointerEnter,
|
||||
onOut: onPointerLeave,
|
||||
} = useInteractionState()
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
return (
|
||||
<View
|
||||
style={[a.mt_sm]}
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}>
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}>
|
||||
<ContentHider
|
||||
modui={moderation?.ui('contentList')}
|
||||
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
|
||||
@@ -284,13 +292,21 @@ export function QuoteEmbed({
|
||||
childContainerStyle={[a.pt_sm]}>
|
||||
{({active}) => (
|
||||
<>
|
||||
{!active && <SubtleHover hover={hover} style={[a.rounded_md]} />}
|
||||
{!active && (
|
||||
<SubtleHover
|
||||
native
|
||||
hover={hover || pressed}
|
||||
style={[a.rounded_md]}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
style={[!active && a.p_md]}
|
||||
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
|
||||
hoverStyle={t.atoms.border_contrast_high}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
onBeforePress={onBeforePress}>
|
||||
onBeforePress={onBeforePress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}>
|
||||
<View pointerEvents="none">
|
||||
<PostMeta
|
||||
author={quote.author}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type React from 'react'
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
|
||||
import {useRequireAuth} from '#/state/session'
|
||||
import {useTheme} from '#/alf'
|
||||
@@ -32,6 +33,7 @@ export const BookmarkButton = memo(function BookmarkButton({
|
||||
const {mutateAsync: bookmark} = useBookmarkMutation()
|
||||
const cleanError = useCleanError()
|
||||
const requireAuth = useRequireAuth()
|
||||
const {feedDescriptor} = useFeedFeedbackContext()
|
||||
|
||||
const {viewer} = post
|
||||
const isBookmarked = !!viewer?.bookmarked
|
||||
@@ -50,7 +52,12 @@ export const BookmarkButton = memo(function BookmarkButton({
|
||||
post,
|
||||
})
|
||||
|
||||
logger.metric('post:bookmark', {logContext})
|
||||
logger.metric('post:bookmark', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
})
|
||||
|
||||
toast.show(
|
||||
<toast.Outer>
|
||||
@@ -85,7 +92,12 @@ export const BookmarkButton = memo(function BookmarkButton({
|
||||
uri: post.uri,
|
||||
})
|
||||
|
||||
logger.metric('post:unbookmark', {logContext})
|
||||
logger.metric('post:unbookmark', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
})
|
||||
|
||||
toast.show(
|
||||
<toast.Outer>
|
||||
|
||||
@@ -98,6 +98,7 @@ let PostMenuItems = ({
|
||||
richText,
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
logContext,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -111,6 +112,7 @@ let PostMenuItems = ({
|
||||
timestamp: string
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
}): React.ReactNode => {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
@@ -210,9 +212,21 @@ let PostMenuItems = ({
|
||||
try {
|
||||
if (isThreadMuted) {
|
||||
unmuteThread()
|
||||
logger.metric('post:unmute', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
Toast.show(_(msg`You will now receive notifications for this thread`))
|
||||
} else {
|
||||
muteThread()
|
||||
logger.metric('post:mute', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
Toast.show(
|
||||
_(msg`You will no longer receive notifications for this thread`),
|
||||
)
|
||||
@@ -272,6 +286,12 @@ let PostMenuItems = ({
|
||||
feedContext: postFeedContext,
|
||||
reqId: postReqId,
|
||||
})
|
||||
logger.metric('post:showMore', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
Toast.show(
|
||||
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
|
||||
)
|
||||
@@ -284,6 +304,12 @@ let PostMenuItems = ({
|
||||
feedContext: postFeedContext,
|
||||
reqId: postReqId,
|
||||
})
|
||||
logger.metric('post:showLess', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
if (onShowLess) {
|
||||
onShowLess({
|
||||
item: postUri,
|
||||
|
||||
@@ -29,6 +29,7 @@ let PostMenuButton = ({
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
hitSlop,
|
||||
logContext,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -41,6 +42,7 @@ let PostMenuButton = ({
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
hitSlop?: Insets
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -87,6 +89,7 @@ let PostMenuButton = ({
|
||||
timestamp={timestamp}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
logContext={logContext}
|
||||
/>
|
||||
)}
|
||||
</Menu.Root>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {useGate} from '#/lib/statsig/statsig'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {native} from '#/alf'
|
||||
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
|
||||
@@ -35,6 +36,7 @@ let ShareMenuButton = ({
|
||||
threadgateRecord,
|
||||
onShare,
|
||||
hitSlop,
|
||||
logContext,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -45,9 +47,11 @@ let ShareMenuButton = ({
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShare: () => void
|
||||
hitSlop?: Insets
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
const {feedDescriptor} = useFeedFeedbackContext()
|
||||
|
||||
const ShareIcon = gate('alt_share_icon')
|
||||
? ArrowShareRightIcon
|
||||
@@ -65,13 +69,27 @@ let ShareMenuButton = ({
|
||||
setTimeout(menuControl.open)
|
||||
|
||||
logger.metric(
|
||||
'share:open',
|
||||
{context: big ? 'thread' : 'feed'},
|
||||
'post:share',
|
||||
{
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
postContext: big ? 'thread' : 'feed',
|
||||
},
|
||||
{statsig: true},
|
||||
)
|
||||
},
|
||||
}),
|
||||
[menuControl, setHasBeenOpen, big],
|
||||
[
|
||||
menuControl,
|
||||
setHasBeenOpen,
|
||||
big,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
post.uri,
|
||||
post.author.did,
|
||||
],
|
||||
)
|
||||
|
||||
const onNativeLongPress = () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {CountWheel} from '#/lib/custom-animations/CountWheel'
|
||||
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/types'
|
||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {
|
||||
@@ -174,6 +175,12 @@ let PostControls = ({
|
||||
feedContext,
|
||||
reqId,
|
||||
})
|
||||
logger.metric('post:clickQuotePost', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
})
|
||||
openComposer({
|
||||
quote: post,
|
||||
onPost: onPostReply,
|
||||
@@ -217,7 +224,16 @@ let PostControls = ({
|
||||
testID="replyBtn"
|
||||
onPress={
|
||||
!replyDisabled
|
||||
? () => requireAuth(() => onPressReply())
|
||||
? () =>
|
||||
requireAuth(() => {
|
||||
logger.metric('post:clickReply', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
feedDescriptor,
|
||||
})
|
||||
onPressReply()
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
label={_(
|
||||
@@ -315,6 +331,7 @@ let PostControls = ({
|
||||
left: secondaryControlSpacingStyles.gap / 2,
|
||||
right: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
logContext={logContext}
|
||||
/>
|
||||
<PostMenuButton
|
||||
testID="postDropdownBtn"
|
||||
@@ -330,6 +347,7 @@ let PostControls = ({
|
||||
hitSlop={{
|
||||
left: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
logContext={logContext}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -330,14 +330,20 @@ export function Name({
|
||||
)
|
||||
}
|
||||
|
||||
export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) {
|
||||
export function Handle({
|
||||
profile,
|
||||
textStyle,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
textStyle?: StyleProp<TextStyle>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
|
||||
return (
|
||||
<Text
|
||||
emoji
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]}
|
||||
numberOfLines={1}>
|
||||
{handle}
|
||||
</Text>
|
||||
|
||||
@@ -389,6 +389,7 @@ let Card = ({
|
||||
{data && moderationOpts ? (
|
||||
status.isActive ? (
|
||||
<LiveStatus
|
||||
status={status}
|
||||
profile={data}
|
||||
embed={status.embed}
|
||||
padding="lg"
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
||||
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
@@ -60,10 +60,16 @@ type Item =
|
||||
key: string
|
||||
}
|
||||
|
||||
export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) {
|
||||
export function FollowDialog({
|
||||
guide,
|
||||
showArrow,
|
||||
}: {
|
||||
guide: Follow10ProgressGuide
|
||||
showArrow?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogControl()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {height: minHeight} = useWindowDimensions()
|
||||
|
||||
return (
|
||||
@@ -74,13 +80,12 @@ export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) {
|
||||
control.open()
|
||||
logEvent('progressGuide:followDialog:open', {})
|
||||
}}
|
||||
size={gtMobile ? 'small' : 'large'}
|
||||
color="primary"
|
||||
variant="solid">
|
||||
<ButtonIcon icon={PersonGroupIcon} />
|
||||
size={gtPhone ? 'small' : 'large'}
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Find people to follow</Trans>
|
||||
</ButtonText>
|
||||
{showArrow && <ButtonIcon icon={ArrowRightIcon} />}
|
||||
</Button>
|
||||
<Dialog.Outer control={control} nativeOptions={{minHeight}}>
|
||||
<Dialog.Handle />
|
||||
@@ -574,6 +579,7 @@ function FollowProfileCardInner({
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
disabledPreview={!isWeb}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
|
||||
@@ -2,37 +2,62 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
useProgressGuide,
|
||||
useProgressGuideControls,
|
||||
} from '#/state/shell/progress-guide'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {Person_Stroke2_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {FollowDialog} from './FollowDialog'
|
||||
import {ProgressGuideTask} from './Task'
|
||||
|
||||
const TOTAL_AVATARS = 10
|
||||
|
||||
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {rightNavVisible} = useLayoutBreakpoints()
|
||||
const {currentAccount} = useSession()
|
||||
const followProgressGuide = useProgressGuide('follow-10')
|
||||
const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7')
|
||||
const guide = followProgressGuide || followAndLikeProgressGuide
|
||||
const {endProgressGuide} = useProgressGuideControls()
|
||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did, {
|
||||
limit: TOTAL_AVATARS,
|
||||
})
|
||||
|
||||
const actualFollowsCount = follows?.pages?.[0]?.follows?.length ?? 0
|
||||
|
||||
// Hide if user already follows 10+ people
|
||||
if (guide?.guide === 'follow-10' && actualFollowsCount >= TOTAL_AVATARS) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Inline layout when left nav visible but no right sidebar (800-1100px)
|
||||
const inlineLayout = gtPhone && !rightNavVisible
|
||||
|
||||
if (guide) {
|
||||
return (
|
||||
<View style={[a.flex_col, a.gap_md, style]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.gap_md,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.p_lg,
|
||||
style,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.font_semi_bold,
|
||||
a.text_sm,
|
||||
{textTransform: 'uppercase'},
|
||||
]}>
|
||||
<Trans>Getting started</Trans>
|
||||
<Text style={[t.atoms.text, a.font_semi_bold, a.text_md]}>
|
||||
<Trans>Follow 10 people to get started</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -40,20 +65,28 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={_(msg`Dismiss getting started guide`)}
|
||||
onPress={endProgressGuide}>
|
||||
<ButtonIcon icon={Times} size="sm" />
|
||||
onPress={endProgressGuide}
|
||||
style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}>
|
||||
<ButtonIcon icon={Times} size="xs" />
|
||||
</Button>
|
||||
</View>
|
||||
{guide.guide === 'follow-10' && (
|
||||
<>
|
||||
<ProgressGuideTask
|
||||
current={guide.numFollows + 1}
|
||||
total={10 + 1}
|
||||
title={_(msg`Follow 10 accounts`)}
|
||||
subtitle={_(msg`Bluesky is better with friends!`)}
|
||||
/>
|
||||
<FollowDialog guide={guide} />
|
||||
</>
|
||||
<View
|
||||
style={[
|
||||
inlineLayout
|
||||
? [
|
||||
a.flex_row,
|
||||
a.flex_wrap,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
]
|
||||
: a.flex_col,
|
||||
!inlineLayout && a.gap_md,
|
||||
]}>
|
||||
<StackedAvatars follows={follows?.pages?.[0]?.follows} />
|
||||
<FollowDialog guide={guide} showArrow={inlineLayout} />
|
||||
</View>
|
||||
)}
|
||||
{guide.guide === 'like-10-and-follow-7' && (
|
||||
<>
|
||||
@@ -76,3 +109,73 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) {
|
||||
const t = useTheme()
|
||||
const {centerColumnOffset} = useLayoutBreakpoints()
|
||||
|
||||
// Smaller avatars for narrower viewport
|
||||
const avatarSize = centerColumnOffset ? 30 : 37
|
||||
const overlap = centerColumnOffset ? 9 : 11
|
||||
const iconSize = centerColumnOffset ? 14 : 18
|
||||
|
||||
// Use actual follows count, not the guide's event counter
|
||||
const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? []
|
||||
const remainingSlots = TOTAL_AVATARS - followedAvatars.length
|
||||
|
||||
// Total width calculation: first avatar + (remaining * visible portion)
|
||||
const totalWidth = avatarSize + (TOTAL_AVATARS - 1) * (avatarSize - overlap)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.self_start, {width: totalWidth}]}>
|
||||
{/* Show followed user avatars */}
|
||||
{followedAvatars.map((follow, i) => (
|
||||
<View
|
||||
key={follow.did}
|
||||
style={[
|
||||
a.rounded_full,
|
||||
{
|
||||
marginLeft: i === 0 ? 0 : -overlap,
|
||||
zIndex: TOTAL_AVATARS - i,
|
||||
borderWidth: 2,
|
||||
borderColor: t.atoms.bg_contrast_25.backgroundColor,
|
||||
},
|
||||
]}>
|
||||
<UserAvatar
|
||||
type="user"
|
||||
size={avatarSize - 4}
|
||||
avatar={follow.avatar}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{/* Show placeholder avatars for remaining slots */}
|
||||
{Array(remainingSlots)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View
|
||||
key={`placeholder-${i}`}
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_100,
|
||||
{
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
marginLeft:
|
||||
followedAvatars.length === 0 && i === 0 ? 0 : -overlap,
|
||||
zIndex: TOTAL_AVATARS - followedAvatars.length - i,
|
||||
borderWidth: 2,
|
||||
borderColor: t.atoms.bg_contrast_25.backgroundColor,
|
||||
},
|
||||
]}>
|
||||
<PersonIcon
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
fill={t.atoms.text_contrast_low.color}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ export function ProgressGuideTask({
|
||||
size={20}
|
||||
thickness={3}
|
||||
borderWidth={0}
|
||||
unfilledColor={t.palette.contrast_50}
|
||||
unfilledColor={t.palette.contrast_100}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={[a.flex_col, a.gap_2xs, subtitle && {marginTop: -2}]}>
|
||||
<View style={[a.flex_col, a.gap_xs, subtitle && {marginTop: -2}]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
|
||||
@@ -11,6 +11,9 @@ import {RichTextTag} from '#/components/RichTextTag'
|
||||
import {Text, type TextProps} from '#/components/Typography'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
// lifted from facet detection in `RichText` impl, _without_ `gm` flags
|
||||
const URL_REGEX =
|
||||
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/i
|
||||
|
||||
export type RichTextProps = TextStyleProp &
|
||||
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
|
||||
@@ -115,7 +118,8 @@ export function RichText({
|
||||
</ProfileHoverCard>,
|
||||
)
|
||||
} else if (link && AppBskyRichtextFacet.validateLink(link).success) {
|
||||
if (disableLinks) {
|
||||
const isValidLink = URL_REGEX.test(link.uri)
|
||||
if (!isValidLink || disableLinks) {
|
||||
els.push(toShortUrl(segment.text))
|
||||
} else {
|
||||
els.push(
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useRemoveMutedWordsMutation,
|
||||
useUpsertMutedWordsMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {
|
||||
@@ -48,10 +48,11 @@ export function RichTextTag({
|
||||
reset: resetRemove,
|
||||
} = useRemoveMutedWordsMutation()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const label = _(msg`Hashtag ${tag}`)
|
||||
const isCashtag = tag.startsWith('$')
|
||||
const label = isCashtag ? _(msg`Cashtag ${tag}`) : _(msg`Hashtag ${tag}`)
|
||||
const hint = isNative
|
||||
? _(msg`Long press to open tag menu for #${tag}`)
|
||||
: _(msg`Click to open tag menu for ${tag}`)
|
||||
? _(msg`Long press to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
|
||||
: _(msg`Click to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
|
||||
|
||||
const isMuted = Boolean(
|
||||
(preferences?.moderationPrefs.mutedWords?.find(
|
||||
@@ -109,20 +110,24 @@ export function RichTextTag({
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`See ${tag} posts`)}
|
||||
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts`)}
|
||||
onPress={() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
})
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>See #{tag} posts</Trans>
|
||||
{isCashtag ? (
|
||||
<Trans>See {tag} posts</Trans>
|
||||
) : (
|
||||
<Trans>See #{tag} posts</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Search} />
|
||||
</Menu.Item>
|
||||
{authorHandle && !isInvalidHandle(authorHandle) && (
|
||||
<Menu.Item
|
||||
label={_(msg`See ${tag} posts by user`)}
|
||||
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts by user`)}
|
||||
onPress={() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
@@ -130,7 +135,11 @@ export function RichTextTag({
|
||||
})
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>See #{tag} posts by user</Trans>
|
||||
{isCashtag ? (
|
||||
<Trans>See {tag} posts by user</Trans>
|
||||
) : (
|
||||
<Trans>See #{tag} posts by user</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Person} />
|
||||
</Menu.Item>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded} from './icons/Times'
|
||||
|
||||
export function SearchError({
|
||||
title,
|
||||
@@ -14,7 +13,7 @@ export function SearchError({
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const pal = usePalette('default')
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<Layout.Content>
|
||||
@@ -27,7 +26,7 @@ export function SearchError({
|
||||
paddingVertical: 150,
|
||||
},
|
||||
]}>
|
||||
<TimesLarge_Stroke2_Corner0_Rounded width={32} fill={pal.colors.icon} />
|
||||
<XIcon width={32} style={[t.atoms.text_contrast_low]} />
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {lazy} from 'react'
|
||||
import {lazy, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
// @ts-expect-error missing types
|
||||
import QRCode from 'react-native-qrcode-styled'
|
||||
@@ -102,39 +102,74 @@ export function QrCode({
|
||||
|
||||
export function QrCodeInner({link}: {link: string}) {
|
||||
const t = useTheme()
|
||||
const [logoArea, setLogoArea] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
} | null>(null)
|
||||
|
||||
const onLogoAreaChange = (area: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}) => {
|
||||
setLogoArea(area)
|
||||
}
|
||||
|
||||
return (
|
||||
<QRCode
|
||||
data={link}
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
|
||||
]}
|
||||
pieceSize={isWeb ? 8 : 6}
|
||||
padding={20}
|
||||
// pieceLiquidRadius={2}
|
||||
pieceBorderRadius={isWeb ? 4.5 : 3.5}
|
||||
outerEyesOptions={{
|
||||
topLeft: {
|
||||
borderRadius: [12, 12, 0, 12],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
topRight: {
|
||||
borderRadius: [12, 12, 12, 0],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
bottomLeft: {
|
||||
borderRadius: [12, 0, 12, 12],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
}}
|
||||
innerEyesOptions={{borderRadius: 3}}
|
||||
logo={{
|
||||
href: require('../../../assets/logo.png'),
|
||||
scale: 0.95,
|
||||
padding: 2,
|
||||
hidePieces: true,
|
||||
}}
|
||||
/>
|
||||
<View style={{position: 'relative'}}>
|
||||
{/* An SVG version of the logo is placed on top of normal `QRCode` `logo` prop, since the PNG fails to load before the export completes on web. */}
|
||||
{isWeb && logoArea && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: logoArea.x,
|
||||
top: logoArea.y + 1,
|
||||
zIndex: 1,
|
||||
padding: 4,
|
||||
}}>
|
||||
<Logo width={logoArea.width - 14} height={logoArea.height - 14} />
|
||||
</View>
|
||||
)}
|
||||
<QRCode
|
||||
data={link}
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
|
||||
]}
|
||||
pieceSize={isWeb ? 8 : 6}
|
||||
padding={20}
|
||||
pieceBorderRadius={isWeb ? 4.5 : 3.5}
|
||||
outerEyesOptions={{
|
||||
topLeft: {
|
||||
borderRadius: [12, 12, 0, 12],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
topRight: {
|
||||
borderRadius: [12, 12, 12, 0],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
bottomLeft: {
|
||||
borderRadius: [12, 0, 12, 12],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
}}
|
||||
innerEyesOptions={{borderRadius: 3}}
|
||||
logo={{
|
||||
href: require('../../../assets/logo.png'),
|
||||
...(isWeb && {
|
||||
onChange: onLogoAreaChange,
|
||||
padding: 28,
|
||||
}),
|
||||
...(!isWeb && {
|
||||
padding: 2,
|
||||
scale: 0.95,
|
||||
}),
|
||||
hidePieces: true,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ export function TrendingTopic({
|
||||
topic: raw,
|
||||
size,
|
||||
style,
|
||||
}: {topic: TrendingTopic; size?: 'large' | 'small'} & ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
hovered,
|
||||
}: {
|
||||
topic: TrendingTopic
|
||||
size?: 'large' | 'small'
|
||||
hovered?: boolean
|
||||
} & ViewStyleProp) {
|
||||
const topic = useTopic(raw)
|
||||
|
||||
const isSmall = size === 'small'
|
||||
@@ -33,18 +37,14 @@ export function TrendingTopic({
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
a.border,
|
||||
t.atoms.border_contrast_medium,
|
||||
t.atoms.bg,
|
||||
isSmall
|
||||
? [
|
||||
{
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
]
|
||||
: [a.py_sm, a.px_md],
|
||||
: [a.py_xs, a.px_sm],
|
||||
hasIcon && {gap: 6},
|
||||
style,
|
||||
]}>
|
||||
@@ -93,6 +93,7 @@ export function TrendingTopic({
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}],
|
||||
hovered && {textDecorationLine: 'underline'},
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{topic.displayName}
|
||||
|
||||
@@ -68,10 +68,12 @@ export function SubscribeProfileButton({
|
||||
|
||||
const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon
|
||||
|
||||
const tooltipVisible = showTooltip && !disableHint
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip.Outer
|
||||
visible={showTooltip && !disableHint}
|
||||
visible={tooltipVisible}
|
||||
onVisibleChange={onDismissTooltip}
|
||||
position="bottom">
|
||||
<Tooltip.Target>
|
||||
@@ -79,7 +81,7 @@ export function SubscribeProfileButton({
|
||||
accessibilityRole="button"
|
||||
testID="dmBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
color={tooltipVisible ? 'primary_subtle' : 'secondary'}
|
||||
shape="round"
|
||||
label={_(msg`Get notified when ${name} posts`)}
|
||||
onPress={wrappedOnPress}>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
|
||||
import {
|
||||
AgeAssuranceInitDialog,
|
||||
useDialogControl,
|
||||
@@ -27,6 +28,13 @@ import {useDeviceGeolocationApi} from '#/geolocation'
|
||||
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
|
||||
const aa = useAgeAssurance()
|
||||
if (aa.state.access === aa.Access.Full) return null
|
||||
if (aa.state.error === 'config') {
|
||||
return (
|
||||
<View style={style}>
|
||||
<AgeAssuranceConfigUnavailableError />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return <Inner style={style} />
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
|
||||
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
|
||||
@@ -19,6 +20,9 @@ export function AgeAssuranceAdmonition({
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
if (aa.state.access === aa.Access.Full) return null
|
||||
if (aa.state.error === 'config') {
|
||||
return <AgeAssuranceConfigUnavailableError style={style} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Inner style={style} control={control}>
|
||||
|
||||
@@ -23,6 +23,7 @@ export function useInternalState() {
|
||||
const visible = useMemo(() => {
|
||||
if (aa.state.access === aa.Access.Full) return false
|
||||
if (aa.state.lastInitiatedAt) return false
|
||||
if (aa.state.error === 'config') return false
|
||||
if (hidden) return false
|
||||
if (nux && nux.completed) return false
|
||||
return true
|
||||
|
||||
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
||||
import {atoms as a, type ViewStyleProp} from '#/alf'
|
||||
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
|
||||
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
@@ -26,33 +27,37 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<View>
|
||||
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
|
||||
{aa.state.error === 'config' ? (
|
||||
<AgeAssuranceConfigUnavailableError />
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {type ViewStyleProp} from '#/alf'
|
||||
import * as Admonition from '#/components/Admonition'
|
||||
import {ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||
import {refetchConfig} from '#/ageAssurance/data'
|
||||
|
||||
export function AgeAssuranceConfigUnavailableError(props: ViewStyleProp) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Admonition.Outer type="error" style={props.style}>
|
||||
<Admonition.Row>
|
||||
<Admonition.Icon />
|
||||
<Admonition.Content>
|
||||
<Admonition.Text>
|
||||
<Trans>
|
||||
We were unable to load the age assurance configuration for your
|
||||
region, probably due to a network error. Some content and features
|
||||
may be unavailable temporarily. Please try again later.
|
||||
</Trans>
|
||||
</Admonition.Text>
|
||||
</Admonition.Content>
|
||||
<Admonition.Button
|
||||
color="negative_subtle"
|
||||
label={_(msg`Retry`)}
|
||||
onPress={() => refetchConfig().catch(() => {})}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={RetryIcon} />
|
||||
</Admonition.Button>
|
||||
</Admonition.Row>
|
||||
</Admonition.Outer>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {isEmailMaybeInvalid} from '#/lib/strings/email'
|
||||
import {type AppLanguage} from '#/locale/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {atoms as a, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
import {urls} from '#/components/ageAssurance/const'
|
||||
@@ -63,7 +63,6 @@ export function AgeAssuranceInitDialog({
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
@@ -323,30 +322,6 @@ function Inner() {
|
||||
/>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
By continuing, you agree to the{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`KWS Terms of Use`)}
|
||||
to={urls.kwsTermsOfUse}
|
||||
style={[a.text_xs, a.leading_snug]}>
|
||||
KWS Terms of Use
|
||||
</SimpleInlineLinkText>{' '}
|
||||
and acknowledge that KWS will store your verified status with
|
||||
your hashed email address in accordance with the{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`KWS Privacy Policy`)}
|
||||
to={urls.kwsPrivacyPolicy}
|
||||
style={[a.text_xs, a.leading_snug]}>
|
||||
KWS Privacy Policy
|
||||
</SimpleInlineLinkText>
|
||||
. This means you won’t need to verify again the next time you
|
||||
use this email for other apps, games, and services powered by
|
||||
KWS technology.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
import {ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
@@ -44,6 +45,12 @@ export function AgeRestrictedScreen({
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<View style={[a.p_lg]}>
|
||||
{aa.state.error === 'config' && (
|
||||
<View style={[a.pb_lg]}>
|
||||
<AgeAssuranceConfigUnavailableError />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.align_start, a.pb_lg]}>
|
||||
<AgeAssuranceBadge />
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Link} from '../Link'
|
||||
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allowlist'
|
||||
|
||||
export function FindContactsBannerNUX() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {visible, close} = useInternalState()
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.p_lg, a.border_b, t.atoms.border_contrast_low]}>
|
||||
<View style={a.w_full}>
|
||||
<Link
|
||||
to={{screen: 'FindContactsFlow'}}
|
||||
label={_(msg`Import contacts to find your friends`)}
|
||||
onPress={() => {
|
||||
logger.metric('contacts:nux:bannerPressed', {})
|
||||
}}
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_xl,
|
||||
a.curve_continuous,
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={[t.palette.primary_200, t.palette.primary_50]}
|
||||
start={{x: 0, y: 0.5}}
|
||||
end={{x: 1, y: 0.5}}
|
||||
style={[
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_lg,
|
||||
a.pl_lg,
|
||||
]}>
|
||||
<Image
|
||||
source={require('../../../assets/images/find_friends_illustration_small.webp')}
|
||||
accessibilityIgnoresInvertColors
|
||||
style={[
|
||||
{height: 70, aspectRatio: 573 / 286},
|
||||
a.self_end,
|
||||
a.mt_sm,
|
||||
]}
|
||||
/>
|
||||
<View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
{color: t.palette.primary_900},
|
||||
]}>
|
||||
<Trans>Import contacts to find your friends</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Link>
|
||||
<Button
|
||||
label={_(msg`Dismiss banner`)}
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={close}
|
||||
style={[a.absolute, {top: 14, right: 14}]}
|
||||
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}>
|
||||
<XIcon size="xs" style={[t.atoms.text_contrast_low]} />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
function useInternalState() {
|
||||
const {nux} = useNux(Nux.FindContactsDismissibleBanner)
|
||||
const {mutate: save, variables} = useSaveNux()
|
||||
const hidden = !!variables
|
||||
const isFeatureEnabled = useIsFindContactsFeatureEnabledBasedOnGeolocation()
|
||||
const gate = useGate()
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (isWeb) return false
|
||||
if (hidden) return false
|
||||
if (nux && nux.completed) return false
|
||||
if (!isFeatureEnabled) return false
|
||||
if (gate('disable_settings_find_contacts')) return false
|
||||
return true
|
||||
}, [hidden, nux, isFeatureEnabled, gate])
|
||||
|
||||
const close = () => {
|
||||
save({
|
||||
id: Nux.FindContactsDismissibleBanner,
|
||||
completed: true,
|
||||
data: undefined,
|
||||
})
|
||||
logger.metric('contacts:nux:bannerDismissed', {})
|
||||
}
|
||||
|
||||
return {visible, close}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {GetContacts} from './screens/GetContacts'
|
||||
import {PhoneInput} from './screens/PhoneInput'
|
||||
import {VerifyNumber} from './screens/VerifyNumber'
|
||||
import {ViewMatches} from './screens/ViewMatches'
|
||||
import {type Action, FindContactsGoBackContext, type State} from './state'
|
||||
|
||||
export function FindContactsFlow({
|
||||
state,
|
||||
dispatch,
|
||||
onBack,
|
||||
onCancel,
|
||||
context = 'Standalone',
|
||||
}: {
|
||||
state: State
|
||||
dispatch: React.ActionDispatch<[Action]>
|
||||
onBack?: () => void
|
||||
onCancel: () => void
|
||||
context: 'Onboarding' | 'Standalone'
|
||||
}) {
|
||||
return (
|
||||
<FindContactsGoBackContext value={onBack}>
|
||||
{state.step === '1: phone input' && (
|
||||
<PhoneInput
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
context={context}
|
||||
onSkip={onCancel}
|
||||
/>
|
||||
)}
|
||||
{state.step === '2: verify number' && (
|
||||
<VerifyNumber
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
context={context}
|
||||
onSkip={onCancel}
|
||||
/>
|
||||
)}
|
||||
{state.step === '3: get contacts' && (
|
||||
<GetContacts
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
onCancel={onCancel}
|
||||
context={context}
|
||||
/>
|
||||
)}
|
||||
{state.step === '4: view matches' && (
|
||||
<ViewMatches
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
context={context}
|
||||
onNext={onCancel}
|
||||
/>
|
||||
)}
|
||||
</FindContactsGoBackContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function FindContactsFlow() {
|
||||
throw new Error('FindContactsFlow is not available on web')
|
||||
}
|
||||