Merge remote-tracking branch 'origin/main' into notification-follow-buttons
* origin/main: (149 commits) Tweak spacing in invite link dialog (#10829) [Chat] Fix source of truth for group size limit (#10845) [Chat] Sync convo agent with shadow cache (#10827) [Chat] Some tweaks (#10840) [Chat] Fix optimistic updates by using partial keys (#10838) Remove leave-convo suppression, use real optimistic update (#10834) use opus 4.8 in github claude (#10837) [Chat] revalidate join links on leave (#10833) Clear search term after adding chat member (#10831) Support dim theme on join request landing page (#10832) Nightly source-language update bskyweb: meta noindex tag for non-indexable pages (#10803) Hide domain if matches handle, new Standard Site icon treatment (#10767) Fix image collapse in FF (#10826) Change Ignore to Reject for chat invites (#10825) [Chat] add lil gap between chats on web (#10821) Add list of mutual chats to block dialog (#10727) Address chat design nits (#10823) Fix link to profile from chat invite embed (#10822) [Chat] Show invite link label in chat list preview (#10811) ...
This commit is contained in:
@@ -0,0 +1 @@
|
||||
alt-blur@3076a2b5a593b0d7b37198b5aebfe3a222afd52d
|
||||
@@ -0,0 +1,44 @@
|
||||
version: 2
|
||||
# Dependabot auto-update config.
|
||||
#
|
||||
# Cooldown (7 days) is the point of this config: it delays version-update
|
||||
# PRs until a newly-published version has aged. Supply-chain attacks like
|
||||
# the tanstack Shai-Hulud compromise (2026-05-11) live minutes-to-hours
|
||||
# before the registry yanks them; a 7-day cooldown keeps poisoned
|
||||
# versions out of our lockfiles.
|
||||
#
|
||||
# Security updates bypass cooldown and continue to flow immediately. See:
|
||||
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown
|
||||
#
|
||||
# Auto-merge is deliberately NOT enabled. Every dependabot PR gets human
|
||||
# review.
|
||||
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
production:
|
||||
dependency-type: production
|
||||
update-types: [minor, patch]
|
||||
development:
|
||||
dependency-type: development
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
update-types: [minor, patch]
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Testflight Build
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Production APK Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
@@ -71,23 +71,23 @@ jobs:
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: Prettier check
|
||||
run: pnpm prettier --check .
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: Prettier check
|
||||
run: pnpm prettier --check .
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -386,7 +386,7 @@ jobs:
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
@@ -51,4 +51,4 @@ jobs:
|
||||
# NOTE(sfn): we can add a custom system prompt here
|
||||
|
||||
claude_args: |
|
||||
--model claude-opus-4-7
|
||||
--model claude-opus-4-8
|
||||
|
||||
@@ -20,6 +20,7 @@ jobs:
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||
- name: Check
|
||||
@@ -37,6 +38,7 @@ jobs:
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||
- name: Lint
|
||||
|
||||
+56
-23
@@ -6,33 +6,42 @@ on:
|
||||
branches:
|
||||
- main
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
# These jobs only check out the repo and run checks, so read access to the
|
||||
# repo contents is all the GITHUB_TOKEN needs.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linting:
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job: [lint, prettier, typecheck]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Verify Node version pins match .nvmrc
|
||||
- name: Verify Node version pins match package.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected=$(tr -d '[:space:]' < .nvmrc)
|
||||
expected=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g, '')")
|
||||
rc=0
|
||||
check() {
|
||||
if [ "$2" != "$expected" ]; then
|
||||
echo "::error file=$1::Node version mismatch: $1 pins '$2' but .nvmrc is '$expected'"
|
||||
echo "::error file=$1::Node version mismatch: $1 pins '$2' but package.json is '$expected'"
|
||||
rc=1
|
||||
fi
|
||||
}
|
||||
# FROM node:X.Y.Z — service runtime images
|
||||
# FROM node:X.Y.Z - service runtime images
|
||||
for f in Dockerfile.bskylink Dockerfile.bskyogcard; do
|
||||
v=$(grep -oE 'FROM node:[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
|
||||
check "$f" "$v"
|
||||
done
|
||||
# ENV NODE_VERSION=X.Y.Z — Go images that nvm-install Node for the JS build stage
|
||||
# ENV NODE_VERSION=X.Y.Z - Go images that nvm-install Node for the JS build stage
|
||||
for f in Dockerfile.embedr; do
|
||||
v=$(grep -oE 'NODE_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
|
||||
check "$f" "$v"
|
||||
@@ -48,22 +57,34 @@ jobs:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
- name: Prettier check
|
||||
run: pnpm prettier --check .
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Check & compile i18n
|
||||
run: pnpm intl:build
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
- name: Lint checks
|
||||
run: pnpm ${{ matrix.job }}
|
||||
# Aggregates the matrix results into a single stable check name so branch
|
||||
# protection can require "Run linters" regardless of how many matrix jobs run.
|
||||
# The result is asserted in `run` (not `if`) so a malformed expression can
|
||||
# never silently skip the check and report a false pass.
|
||||
linting-summary:
|
||||
name: Run linters
|
||||
if: always()
|
||||
needs: [linting]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require linting to have succeeded
|
||||
env:
|
||||
RESULT: ${{ needs.linting.result }}
|
||||
run: |
|
||||
echo "linting result: $RESULT"
|
||||
test "$RESULT" = "success"
|
||||
testing:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
@@ -74,13 +95,25 @@ jobs:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Check & compile i18n
|
||||
run: pnpm intl:build
|
||||
- name: Run tests
|
||||
run: |
|
||||
NODE_ENV=test pnpm test --forceExit
|
||||
NODE_ENV=test pnpm test --forceExit --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
# Aggregates the sharded test results into a single stable check name so branch
|
||||
# protection can require "Run tests" regardless of how many shards run.
|
||||
# The result is asserted in `run` (not `if`) so a malformed expression can
|
||||
# never silently skip the check and report a false pass.
|
||||
testing-summary:
|
||||
name: Run tests
|
||||
if: always()
|
||||
needs: [testing]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require testing to have succeeded
|
||||
env:
|
||||
RESULT: ${{ needs.testing.result }}
|
||||
run: |
|
||||
echo "testing result: $RESULT"
|
||||
test "$RESULT" = "success"
|
||||
|
||||
@@ -26,11 +26,7 @@ jobs:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Extract language strings
|
||||
run: pnpm intl:extract
|
||||
- name: Create commit
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
core.setOutput('head-ref', pr.data.head.ref);
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
@@ -128,20 +128,20 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
RUNTIME_VERSION:
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
if: failure()
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
with:
|
||||
header: bundle-diff
|
||||
message: |
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
profile: pull-request
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Delete comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||
|
||||
@@ -34,12 +34,8 @@ jobs:
|
||||
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
|
||||
|
||||
- name: pnpm install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
# Fine to skip scripts since we don't run any code
|
||||
command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
# Fine to skip scripts since we don't run any code
|
||||
run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Verify pnpm-lock.yaml
|
||||
run: |
|
||||
|
||||
@@ -133,3 +133,4 @@ bskyweb/static/media/*.svg
|
||||
|
||||
# superpowers plugin plans/specs — local-only workspace
|
||||
docs/superpowers/
|
||||
.claude/worktrees
|
||||
|
||||
@@ -206,7 +206,7 @@ function MyComponent() {
|
||||
|
||||
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>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -414,12 +414,16 @@ 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>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
|
||||
|
||||
// For text with emoji, add the emoji prop
|
||||
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
|
||||
// will almost certainly contain emoji, so only omit it when the text is static and
|
||||
// does not contain an emoji
|
||||
<Text emoji>Hello! 👋</Text>
|
||||
```
|
||||
|
||||
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
|
||||
|
||||
### TextField
|
||||
|
||||
```tsx
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
|
||||
RUN \. "$NVM_DIR/nvm.sh" && \
|
||||
nvm install $NODE_VERSION && \
|
||||
nvm use $NODE_VERSION && \
|
||||
npm install --global pnpm@11.1.3 && \
|
||||
npm install --global pnpm@11.5.2 && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
||||
pnpm intl:build && \
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
|
||||
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
|
||||
import {
|
||||
downloadAndResize,
|
||||
type DownloadAndResizeOpts,
|
||||
getResizedDimensions,
|
||||
} from '../../src/lib/media/manip'
|
||||
import {getResizedDimensions} from '../../src/lib/media/util'
|
||||
|
||||
const mockResizedImage = {
|
||||
path: 'file://resized-image.jpg',
|
||||
@@ -41,10 +42,8 @@ describe('downloadAndResize', () => {
|
||||
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'https://example.com/image.jpg',
|
||||
width: 100,
|
||||
height: 100,
|
||||
maxDimension: 2000,
|
||||
maxSize: 500000,
|
||||
mode: 'cover',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -60,9 +59,11 @@ describe('downloadAndResize', () => {
|
||||
|
||||
// First time it gets called is to get dimensions
|
||||
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
|
||||
// The mocked source image is 100x100, below maxDimension, so it is not
|
||||
// downsized.
|
||||
expect(manipulateAsync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[{resize: {height: opts.height, width: opts.width}}],
|
||||
[{resize: {height: 100, width: 100}}],
|
||||
{format: SaveFormat.JPEG, compress: 1.0},
|
||||
)
|
||||
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
|
||||
@@ -73,10 +74,8 @@ describe('downloadAndResize', () => {
|
||||
it('should return undefined for invalid URI', async () => {
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'invalid-uri',
|
||||
width: 100,
|
||||
height: 100,
|
||||
maxDimension: 2000,
|
||||
maxSize: 500000,
|
||||
mode: 'cover',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -90,13 +89,19 @@ describe('downloadAndResize', () => {
|
||||
width: 1200,
|
||||
height: 1000,
|
||||
}
|
||||
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
|
||||
const resizedDimensionsOne = getResizedDimensions(
|
||||
initialDimensionsOne,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
const initialDimensionsTwo = {
|
||||
width: 1000,
|
||||
height: 1200,
|
||||
}
|
||||
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
|
||||
const resizedDimensionsTwo = getResizedDimensions(
|
||||
initialDimensionsTwo,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
|
||||
expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
|
||||
@@ -107,13 +112,19 @@ describe('downloadAndResize', () => {
|
||||
width: 3000,
|
||||
height: 1500,
|
||||
}
|
||||
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
|
||||
const resizedDimensionsOne = getResizedDimensions(
|
||||
initialDimensionsOne,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
const initialDimensionsTwo = {
|
||||
width: 2000,
|
||||
height: 4000,
|
||||
}
|
||||
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
|
||||
const resizedDimensionsTwo = getResizedDimensions(
|
||||
initialDimensionsTwo,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
expect(resizedDimensionsOne).toEqual({
|
||||
width: 2000,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
|
||||
import {
|
||||
getChatInviteCodeFromUrl,
|
||||
isPossiblyAUrl,
|
||||
isTrustedUrl,
|
||||
linkRequiresWarning,
|
||||
@@ -178,3 +179,47 @@ describe('isTrustedUrl', () => {
|
||||
expect(output).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getChatInviteCodeFromUrl', () => {
|
||||
type Case = [string, string | undefined]
|
||||
|
||||
const cases: Case[] = [
|
||||
['https://bsky.app/chat/abcdefg', 'abcdefg'],
|
||||
['https://bsky.app/chat/abcdefghij', 'abcdefghij'],
|
||||
// http is not recognized as a bsky.app url
|
||||
['http://bsky.app/chat/abcdefg', undefined],
|
||||
['https://bsky.app/chat/abcdefg?utm=foo', 'abcdefg'],
|
||||
['https://bsky.app/chat/abcdefg#section', 'abcdefg'],
|
||||
['/chat/abcdefg', 'abcdefg'],
|
||||
['/chat/abcdefg?utm=foo', 'abcdefg'],
|
||||
['/chat/abcdefg#section', 'abcdefg'],
|
||||
|
||||
// too short
|
||||
['https://bsky.app/chat/abcdef', undefined],
|
||||
['/chat/abcdef', undefined],
|
||||
// too long
|
||||
['https://bsky.app/chat/abcdefghijk', undefined],
|
||||
['/chat/abcdefghijk', undefined],
|
||||
// invalid characters
|
||||
['https://bsky.app/chat/abc-def', undefined],
|
||||
['/chat/abc def', undefined],
|
||||
// trailing path
|
||||
['https://bsky.app/chat/abcdefg/extra', undefined],
|
||||
['/chat/abcdefg/extra', undefined],
|
||||
// wrong path
|
||||
['https://bsky.app/profile/abcdefg', undefined],
|
||||
['https://bsky.app/chat', undefined],
|
||||
// wrong host
|
||||
['https://example.com/chat/abcdefg', undefined],
|
||||
// not a url, not a path
|
||||
['chat/abcdefg', undefined],
|
||||
['abcdefg', undefined],
|
||||
['', undefined],
|
||||
// malformed url
|
||||
['https://[invalid/chat/abcdefg', undefined],
|
||||
]
|
||||
|
||||
it.each(cases)('given input %p, returns %p', (input, expected) => {
|
||||
expect(getChatInviteCodeFromUrl(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-1
@@ -349,7 +349,7 @@ module.exports = function (_config) {
|
||||
},
|
||||
],
|
||||
[
|
||||
'@mozzius/expo-dynamic-app-icon',
|
||||
'@bsky.app/expo-dynamic-app-icon',
|
||||
{
|
||||
/**
|
||||
* Default set
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -18,7 +18,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.6",
|
||||
"@atproto/api": "0.20.11",
|
||||
"@atproto/common": "^0.6.1",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"express": "^4.19.2",
|
||||
|
||||
Generated
+5
-5
@@ -208,8 +208,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.6
|
||||
version: 0.20.6
|
||||
specifier: 0.20.11
|
||||
version: 0.20.11
|
||||
'@atproto/common':
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1
|
||||
@@ -259,8 +259,8 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@atproto/api@0.20.6':
|
||||
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
|
||||
'@atproto/api@0.20.11':
|
||||
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -1154,7 +1154,7 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@atproto/api@0.20.6':
|
||||
'@atproto/api@0.20.11':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {type ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {ChatBskyGroupDefs} from '@atproto/api'
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {type Express} from 'express'
|
||||
import satori from 'satori'
|
||||
@@ -32,7 +32,7 @@ export default function (ctx: AppContext, app: Express) {
|
||||
codes: [code],
|
||||
})
|
||||
const found = result.data.joinLinkPreviews[0]
|
||||
if (!found) {
|
||||
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(found)) {
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
preview = found
|
||||
|
||||
@@ -136,9 +136,9 @@ func bskyProfileURL(handle string) string {
|
||||
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
|
||||
}
|
||||
|
||||
// extractPostMedia returns thumbnail URLs for the post's image or video
|
||||
// embed, byte-identical to what we put in og:image. Callers derive
|
||||
// thumbnailUrl from urls[0].
|
||||
// extractPostMedia returns thumbnail URLs for the post's image, gallery,
|
||||
// or video embed, byte-identical to what we put in og:image. Callers
|
||||
// derive thumbnailUrl from urls[0].
|
||||
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
|
||||
if pv == nil || pv.Embed == nil || embedHidden {
|
||||
return nil
|
||||
@@ -147,6 +147,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
|
||||
if pv.Embed.EmbedImages_View != nil {
|
||||
return imageThumbs(pv.Embed.EmbedImages_View.Images)
|
||||
}
|
||||
if pv.Embed.EmbedGallery_View != nil {
|
||||
return galleryThumbs(pv.Embed.EmbedGallery_View.Items)
|
||||
}
|
||||
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
|
||||
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
@@ -155,6 +158,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
|
||||
if media.EmbedImages_View != nil {
|
||||
return imageThumbs(media.EmbedImages_View.Images)
|
||||
}
|
||||
if media.EmbedGallery_View != nil {
|
||||
return galleryThumbs(media.EmbedGallery_View.Items)
|
||||
}
|
||||
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
|
||||
return []string{*media.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
@@ -174,6 +180,31 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
|
||||
return urls
|
||||
}
|
||||
|
||||
// galleryThumbs returns the thumbnail URLs of image items in a gallery
|
||||
// embed, or nil if empty. Items_Elem is a union; non-image variants and
|
||||
// nil entries are skipped so future gallery item types don't break SEO
|
||||
// extraction. Empty Thumbnail strings are also skipped to avoid emitting
|
||||
// <meta property="og:image" content=""> if the appview ever returns one.
|
||||
func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
urls := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil || item.EmbedGallery_ViewImage == nil {
|
||||
continue
|
||||
}
|
||||
if item.EmbedGallery_ViewImage.Thumbnail == "" {
|
||||
continue
|
||||
}
|
||||
urls = append(urls, item.EmbedGallery_ViewImage.Thumbnail)
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
|
||||
// if the embed is blocked / not-found / detached / a non-post record.
|
||||
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
||||
|
||||
@@ -72,6 +72,60 @@ func withImages(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
|
||||
}
|
||||
}
|
||||
|
||||
// withGallery adds an app.bsky.embed.gallery view with image items.
|
||||
func withGallery(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
var items []*appbsky.EmbedGallery_View_Items_Elem
|
||||
for _, t := range thumbs {
|
||||
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
|
||||
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
|
||||
Thumbnail: t,
|
||||
Fullsize: t + "_full",
|
||||
},
|
||||
})
|
||||
}
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withRecordWithMediaGallery adds a record-with-media embed whose media slot
|
||||
// is an app.bsky.embed.gallery view.
|
||||
func withRecordWithMediaGallery(qHandle, qDid, qRkey string, thumbs ...string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
var items []*appbsky.EmbedGallery_View_Items_Elem
|
||||
for _, t := range thumbs {
|
||||
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
|
||||
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
|
||||
Thumbnail: t,
|
||||
Fullsize: t + "_full",
|
||||
},
|
||||
})
|
||||
}
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
|
||||
Record: &appbsky.EmbedRecord_View{
|
||||
Record: &appbsky.EmbedRecord_View_Record{
|
||||
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
|
||||
Uri: "at://" + qDid + "/app.bsky.feed.post/" + qRkey,
|
||||
Cid: "bafy-quoted",
|
||||
Author: &appbsky.ActorDefs_ProfileViewBasic{
|
||||
Did: qDid,
|
||||
Handle: qHandle,
|
||||
},
|
||||
IndexedAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
Media: &appbsky.EmbedRecordWithMedia_View_Media{
|
||||
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withVideo adds a video embed with a thumbnail.
|
||||
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
@@ -299,6 +353,88 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_WithGallery(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
||||
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
imgs, ok := main["image"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("image should be array, got %T", main["image"])
|
||||
}
|
||||
if len(imgs) != 3 {
|
||||
t.Errorf("expected 3 gallery images, got %d", len(imgs))
|
||||
}
|
||||
if imgs[0] != thumb1 || imgs[1] != thumb2 || imgs[2] != thumb3 {
|
||||
t.Errorf("gallery image[] order wrong: %v", imgs)
|
||||
}
|
||||
if main["thumbnailUrl"] != thumb1 {
|
||||
t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery in the media slot of a record-with-media embed should still
|
||||
// produce og:image / JSON-LD image[]. Quote-post URL still emits
|
||||
// alongside via isBasedOn.
|
||||
func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
|
||||
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
imgs, ok := main["image"].([]any)
|
||||
if !ok || len(imgs) != 1 || imgs[0] != thumb {
|
||||
t.Errorf("expected single gallery thumb in image[], got %v", main["image"])
|
||||
}
|
||||
if main["thumbnailUrl"] != thumb {
|
||||
t.Errorf("thumbnailUrl wrong: %v", main["thumbnailUrl"])
|
||||
}
|
||||
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
|
||||
t.Errorf("isBasedOn should still emit for record-with-media gallery, got %v", main["isBasedOn"])
|
||||
}
|
||||
}
|
||||
|
||||
// Forward-compat: nil items, unknown-variant union elements, and empty
|
||||
// Thumbnail strings must be skipped, not panic or leak as <meta
|
||||
// property="og:image" content="">. Unknown variants are dropped silently
|
||||
// so older deploys keep working when new gallery item types ship.
|
||||
func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery")
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedGallery_View: &appbsky.EmbedGallery_View{
|
||||
Items: []*appbsky.EmbedGallery_View_Items_Elem{
|
||||
nil,
|
||||
{}, // empty union, no variant set
|
||||
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: ""}}, // empty Thumbnail
|
||||
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: thumb}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := extractPostMedia(pv, false)
|
||||
if len(got) != 1 || got[0] != thumb {
|
||||
t.Errorf("expected single thumb, got %v", got)
|
||||
}
|
||||
|
||||
// All-nil / all-unknown gallery should produce no thumbs (not [""]).
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedGallery_View: &appbsky.EmbedGallery_View{
|
||||
Items: []*appbsky.EmbedGallery_View_Items_Elem{nil, {}},
|
||||
},
|
||||
}
|
||||
if got := extractPostMedia(pv, false); got != nil {
|
||||
t.Errorf("expected nil for empty/unknown-only gallery, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
|
||||
@@ -361,6 +497,25 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Symmetric guard for the gallery extraction path. Functionally redundant
|
||||
// with the early-return at the top of extractPostMedia, but exists so the
|
||||
// hide-embed contract is asserted directly against the gallery branch -
|
||||
// catches anyone who later moves the embedHidden check inside an
|
||||
// embed-shape branch.
|
||||
func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/g@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||
withGallery(thumb), withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["image"]; present {
|
||||
t.Errorf("hidden-embed gallery post should not emit image")
|
||||
}
|
||||
if _, present := main["thumbnailUrl"]; present {
|
||||
t.Errorf("hidden-embed gallery post should not emit thumbnailUrl")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
|
||||
// Includes ", \, newline, </script>, and a unicode char.
|
||||
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
||||
|
||||
@@ -46,6 +46,12 @@ func run(args []string) {
|
||||
Required: false,
|
||||
EnvVars: []string{"OGCARD_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "chat-host",
|
||||
Usage: "scheme, hostname, and port of chat appview",
|
||||
Required: false,
|
||||
EnvVars: []string{"CHAT_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-address",
|
||||
Usage: "Specify the local IP/port to bind to",
|
||||
|
||||
@@ -43,6 +43,13 @@ func extractJSONLD(t *testing.T, html string) string {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func TestRenderBase_NoindexMeta(t *testing.T) {
|
||||
html := renderTemplate(t, "base.html", pongo2.Context{"noindex": true, "nofollow": true})
|
||||
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
|
||||
t.Errorf("expected combined noindex,nofollow meta; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPost_EmitsJSONLD(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
@@ -98,6 +105,40 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery posts must hit the same og:image / JSON-LD image[] byte-equality
|
||||
// contract that legacy images posts do. Regression guard for the
|
||||
// app.bsky.embed.gallery extraction path.
|
||||
func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
|
||||
thumbs := extractPostMedia(pv, false)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"postJSONLD": ld,
|
||||
"imgThumbUrls": thumbs,
|
||||
})
|
||||
|
||||
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
|
||||
t.Errorf("og:image[0] not found in rendered HTML for gallery post")
|
||||
}
|
||||
if !strings.Contains(html, `<meta property="og:image" content="`+thumb2+`">`) {
|
||||
t.Errorf("og:image[1] not found in rendered HTML for gallery post")
|
||||
}
|
||||
body := extractJSONLD(t, html)
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal([]byte(body), &parsed)
|
||||
main := parsed["mainEntity"].(map[string]any)
|
||||
imgs := main["image"].([]any)
|
||||
if len(imgs) != 2 || imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
|
||||
t.Errorf("JSON-LD image strings drifted from og:image for gallery; image=%v thumbnailUrl=%v",
|
||||
imgs, main["thumbnailUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
||||
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
@@ -209,3 +250,50 @@ func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
|
||||
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth-required posts must emit noindex,nofollow so the stub page (no body
|
||||
// text, no comments) is not indexed.
|
||||
func TestRenderPost_AuthRequiredNoindex(t *testing.T) {
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"requiresAuth": true,
|
||||
"profileHandle": "alice.bsky.social",
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"noindex": true,
|
||||
"nofollow": true,
|
||||
})
|
||||
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
|
||||
t.Errorf("auth-required post should emit noindex,nofollow; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth-required profiles must emit noindex,nofollow.
|
||||
func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
|
||||
pv := newProfileViewDetailed()
|
||||
html := renderTemplate(t, "profile.html", pongo2.Context{
|
||||
"profileView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social",
|
||||
"requiresAuth": true,
|
||||
"noindex": true,
|
||||
"nofollow": true,
|
||||
})
|
||||
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
|
||||
t.Errorf("auth-required profile should emit noindex,nofollow; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// Public posts must NOT emit a robots meta tag. Guards against an accidental
|
||||
// flip of the noindex flag for indexable pages.
|
||||
func TestRenderPost_PublicNoNoindex(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"postJSONLD": ld,
|
||||
})
|
||||
if strings.Contains(html, `<meta name="robots"`) {
|
||||
t.Errorf("public post should not emit robots meta; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
+140
-48
@@ -17,11 +17,13 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
chat "github.com/bluesky-social/indigo/api/chat"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
"github.com/bluesky-social/indigo/util/cliutil"
|
||||
"github.com/bluesky-social/indigo/xrpc"
|
||||
@@ -37,10 +39,11 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
chatXrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
|
||||
ipccClient http.Client
|
||||
|
||||
@@ -54,6 +57,7 @@ type Config struct {
|
||||
debug bool
|
||||
httpAddress string
|
||||
appviewHost string
|
||||
chatHost string
|
||||
ogcardHost string
|
||||
linkHost string
|
||||
ipccHost string
|
||||
@@ -64,6 +68,7 @@ func serve(cctx *cli.Context) error {
|
||||
debug := cctx.Bool("debug")
|
||||
httpAddress := cctx.String("http-address")
|
||||
appviewHost := cctx.String("appview-host")
|
||||
chatHost := cctx.String("chat-host")
|
||||
ogcardHost := cctx.String("ogcard-host")
|
||||
linkHost := cctx.String("link-host")
|
||||
ipccHost := cctx.String("ipcc-host")
|
||||
@@ -83,6 +88,15 @@ func serve(cctx *cli.Context) error {
|
||||
Host: appviewHost,
|
||||
}
|
||||
|
||||
// optional client for the chat appview, used by /chat/<code> for OG previews.
|
||||
var chatXrpcc *xrpc.Client
|
||||
if chatHost != "" {
|
||||
chatXrpcc = &xrpc.Client{
|
||||
Client: cliutil.NewHttpClient(),
|
||||
Host: chatHost,
|
||||
}
|
||||
}
|
||||
|
||||
// httpd
|
||||
var (
|
||||
httpTimeout = 2 * time.Minute
|
||||
@@ -106,12 +120,14 @@ func serve(cctx *cli.Context) error {
|
||||
// server
|
||||
//
|
||||
server := &Server{
|
||||
echo: e,
|
||||
xrpcc: xrpcc,
|
||||
echo: e,
|
||||
xrpcc: xrpcc,
|
||||
chatXrpcc: chatXrpcc,
|
||||
cfg: &Config{
|
||||
debug: debug,
|
||||
httpAddress: httpAddress,
|
||||
appviewHost: appviewHost,
|
||||
chatHost: chatHost,
|
||||
ogcardHost: ogcardHost,
|
||||
linkHost: linkHost,
|
||||
ipccHost: ipccHost,
|
||||
@@ -280,49 +296,50 @@ func serve(cctx *cli.Context) error {
|
||||
// generic routes
|
||||
e.GET("/hashtag/:tag", server.WebGeneric)
|
||||
e.GET("/topic/:topic", server.WebGeneric)
|
||||
e.GET("/search", server.WebGeneric)
|
||||
e.GET("/feeds", server.WebGeneric)
|
||||
e.GET("/notifications", server.WebGeneric)
|
||||
e.GET("/notifications/settings", server.WebGeneric)
|
||||
e.GET("/notifications/activity", server.WebGeneric)
|
||||
e.GET("/lists", server.WebGeneric)
|
||||
e.GET("/moderation", server.WebGeneric)
|
||||
e.GET("/moderation/modlists", server.WebGeneric)
|
||||
e.GET("/moderation/muted-accounts", server.WebGeneric)
|
||||
e.GET("/moderation/blocked-accounts", server.WebGeneric)
|
||||
e.GET("/moderation/verification-settings", server.WebGeneric)
|
||||
e.GET("/settings", server.WebGeneric)
|
||||
e.GET("/settings/language", server.WebGeneric)
|
||||
e.GET("/settings/app-passwords", server.WebGeneric)
|
||||
e.GET("/settings/following-feed", server.WebGeneric)
|
||||
e.GET("/settings/saved-feeds", server.WebGeneric)
|
||||
e.GET("/settings/threads", server.WebGeneric)
|
||||
e.GET("/settings/external-embeds", server.WebGeneric)
|
||||
e.GET("/settings/accessibility", server.WebGeneric)
|
||||
e.GET("/settings/appearance", server.WebGeneric)
|
||||
e.GET("/settings/account", server.WebGeneric)
|
||||
e.GET("/settings/automation-label", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
|
||||
e.GET("/settings/content-and-media", server.WebGeneric)
|
||||
e.GET("/settings/interests", server.WebGeneric)
|
||||
e.GET("/settings/about", server.WebGeneric)
|
||||
e.GET("/settings/notifications", server.WebGeneric)
|
||||
e.GET("/sys/debug", server.WebGeneric)
|
||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||
e.GET("/sys/log", server.WebGeneric)
|
||||
e.GET("/search", server.WebGenericNoindex)
|
||||
e.GET("/feeds", server.WebGenericNoindex)
|
||||
e.GET("/notifications", server.WebGenericNoindex)
|
||||
e.GET("/notifications/settings", server.WebGenericNoindex)
|
||||
e.GET("/notifications/activity", server.WebGenericNoindex)
|
||||
e.GET("/lists", server.WebGenericNoindex)
|
||||
e.GET("/moderation", server.WebGenericNoindex)
|
||||
e.GET("/moderation/modlists", server.WebGenericNoindex)
|
||||
e.GET("/moderation/muted-accounts", server.WebGenericNoindex)
|
||||
e.GET("/moderation/blocked-accounts", server.WebGenericNoindex)
|
||||
e.GET("/moderation/verification-settings", server.WebGenericNoindex)
|
||||
e.GET("/settings", server.WebGenericNoindex)
|
||||
e.GET("/settings/language", server.WebGenericNoindex)
|
||||
e.GET("/settings/app-passwords", server.WebGenericNoindex)
|
||||
e.GET("/settings/following-feed", server.WebGenericNoindex)
|
||||
e.GET("/settings/saved-feeds", server.WebGenericNoindex)
|
||||
e.GET("/settings/threads", server.WebGenericNoindex)
|
||||
e.GET("/settings/external-embeds", server.WebGenericNoindex)
|
||||
e.GET("/settings/accessibility", server.WebGenericNoindex)
|
||||
e.GET("/settings/appearance", server.WebGenericNoindex)
|
||||
e.GET("/settings/account", server.WebGenericNoindex)
|
||||
e.GET("/settings/automation-label", server.WebGenericNoindex)
|
||||
e.GET("/settings/privacy-and-security", server.WebGenericNoindex)
|
||||
e.GET("/settings/privacy-and-security/activity", server.WebGenericNoindex)
|
||||
e.GET("/settings/content-and-media", server.WebGenericNoindex)
|
||||
e.GET("/settings/interests", server.WebGenericNoindex)
|
||||
e.GET("/settings/about", server.WebGenericNoindex)
|
||||
e.GET("/settings/notifications", server.WebGenericNoindex)
|
||||
e.GET("/sys/debug", server.WebGenericNoindex)
|
||||
e.GET("/sys/debug-mod", server.WebGenericNoindex)
|
||||
e.GET("/sys/log", server.WebGenericNoindex)
|
||||
e.GET("/support", server.WebGeneric)
|
||||
e.GET("/support/privacy", server.WebGeneric)
|
||||
e.GET("/support/tos", server.WebGeneric)
|
||||
e.GET("/support/community-guidelines", server.WebGeneric)
|
||||
e.GET("/support/copyright", server.WebGeneric)
|
||||
e.GET("/intent/compose", server.WebGeneric)
|
||||
e.GET("/intent/verify-email", server.WebGeneric)
|
||||
e.GET("/intent/age-assurance", server.WebGeneric)
|
||||
e.GET("/messages", server.WebGeneric)
|
||||
e.GET("/messages/inbox", server.WebGeneric)
|
||||
e.GET("/messages/:conversation", server.WebGeneric)
|
||||
e.GET("/messages/:conversation/settings", server.WebGeneric)
|
||||
e.GET("/intent/compose", server.WebGenericNoindexNofollow)
|
||||
e.GET("/intent/verify-email", server.WebGenericNoindexNofollow)
|
||||
e.GET("/intent/age-assurance", server.WebGenericNoindexNofollow)
|
||||
e.GET("/messages", server.WebGenericNoindex)
|
||||
e.GET("/messages/inbox", server.WebGenericNoindex)
|
||||
e.GET("/messages/:conversation", server.WebGenericNoindex)
|
||||
e.GET("/messages/:conversation/settings", server.WebGenericNoindex)
|
||||
e.GET("/messages/:conversation/requests", server.WebGenericNoindex)
|
||||
|
||||
// profile endpoints; only first populates info
|
||||
e.GET("/profile/:handleOrDID", server.WebProfile)
|
||||
@@ -346,11 +363,14 @@ func serve(cctx *cli.Context) error {
|
||||
|
||||
// starter packs
|
||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
e.GET("/starter-pack-short/:code", server.WebGeneric)
|
||||
e.GET("/starter-pack-short/:code", server.WebGenericNoindex)
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
|
||||
// chat invites
|
||||
e.GET("/chat/:code", server.WebChatInvite)
|
||||
|
||||
// bookmarks
|
||||
e.GET("/saved", server.WebGeneric)
|
||||
e.GET("/saved", server.WebGenericNoindex)
|
||||
|
||||
// ipcc
|
||||
e.GET("/ipcc", server.WebIpCC)
|
||||
@@ -417,6 +437,8 @@ func (srv *Server) NewTemplateContext() pongo2.Context {
|
||||
return pongo2.Context{
|
||||
"staticCDNHost": srv.cfg.staticCDNHost,
|
||||
"favicon": fmt.Sprintf("%s/static/favicon.png", srv.cfg.staticCDNHost),
|
||||
"noindex": false,
|
||||
"nofollow": false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,10 +491,38 @@ func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
|
||||
)
|
||||
}
|
||||
|
||||
// renderOptions controls per-request rendering flags for the generic web handler.
|
||||
type renderOptions struct {
|
||||
noindex bool
|
||||
nofollow bool
|
||||
}
|
||||
|
||||
// webGeneric returns a handler that renders the base SPA shell with the given
|
||||
// render options applied to the template context.
|
||||
func (srv *Server) webGeneric(c echo.Context, o renderOptions) error {
|
||||
data := srv.NewTemplateContext()
|
||||
data["noindex"] = o.noindex
|
||||
data["nofollow"] = o.nofollow
|
||||
return c.Render(http.StatusOK, "base.html", data)
|
||||
}
|
||||
|
||||
// handler for endpoint that have no specific server-side handling
|
||||
func (srv *Server) WebGeneric(c echo.Context) error {
|
||||
data := srv.NewTemplateContext()
|
||||
return c.Render(http.StatusOK, "base.html", data)
|
||||
return srv.webGeneric(c, renderOptions{})
|
||||
}
|
||||
|
||||
// handler for routes that should not be indexed by search engines
|
||||
// (e.g. auth-only user-state surfaces, internal/debug pages, action/intent dispatch URLs, search results)
|
||||
func (srv *Server) WebGenericNoindex(c echo.Context) error {
|
||||
return srv.webGeneric(c, renderOptions{noindex: true})
|
||||
}
|
||||
|
||||
// handler for action/intent dispatch URLs (e.g. /intent/compose). These accept
|
||||
// arbitrary query parameters from arbitrary third-party referrers, so we treat
|
||||
// them as link-graph dead-ends in addition to noindex. Anything legitimately
|
||||
// reachable from a hydrated intent page is also reachable via its canonical URL.
|
||||
func (srv *Server) WebGenericNoindexNofollow(c echo.Context) error {
|
||||
return srv.webGeneric(c, renderOptions{noindex: true, nofollow: true})
|
||||
}
|
||||
|
||||
func (srv *Server) WebHome(c echo.Context) error {
|
||||
@@ -547,6 +597,8 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
data["canonicalURL"] = canonicalURL
|
||||
}
|
||||
data["requiresAuth"] = true
|
||||
data["noindex"] = true
|
||||
data["nofollow"] = true
|
||||
data["profileHandle"] = pv.Handle
|
||||
if pv.DisplayName != nil {
|
||||
data["profileDisplayName"] = *pv.DisplayName
|
||||
@@ -644,6 +696,44 @@ func (srv *Server) WebStarterPack(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
|
||||
// chatInviteCodeRe is a permissive sanity check on the join code so we don't
|
||||
// proxy obviously-bad input to the chat appview. Codes are opaque per the
|
||||
// lexicon, but in practice URL-safe and short.
|
||||
var chatInviteCodeRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
func (srv *Server) WebChatInvite(c echo.Context) error {
|
||||
req := c.Request()
|
||||
ctx := req.Context()
|
||||
data := srv.NewTemplateContext()
|
||||
data["noindex"] = true
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
|
||||
code := c.Param("code")
|
||||
if !chatInviteCodeRe.MatchString(code) {
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
}
|
||||
if srv.chatXrpcc == nil {
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
}
|
||||
|
||||
out, err := chat.GroupGetJoinLinkPreviews(ctx, srv.chatXrpcc, []string{code})
|
||||
if err != nil {
|
||||
log.Errorf("failed to fetch chat invite preview for code=%s: %v", code, err)
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
}
|
||||
if len(out.JoinLinkPreviews) == 0 {
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
}
|
||||
preview := out.JoinLinkPreviews[0]
|
||||
|
||||
data["title"] = preview.Name
|
||||
if srv.cfg.ogcardHost != "" {
|
||||
// bskyogcard registers this route as /chat-invite/:code, not /chat/:code.
|
||||
data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code)
|
||||
}
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebProfile(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
data := srv.NewTemplateContext()
|
||||
@@ -703,6 +793,8 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
||||
}
|
||||
} else {
|
||||
data["requiresAuth"] = true
|
||||
data["noindex"] = true
|
||||
data["nofollow"] = true
|
||||
}
|
||||
|
||||
if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20260528134852-3e368bd18814
|
||||
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c
|
||||
github.com/flosch/pongo2/v6 v6.0.0
|
||||
github.com/ipfs/go-log v1.0.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
||||
+2
-4
@@ -2,10 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260527160159-3b7634c713b8 h1:heYuihJZQZMNij7+dKIEfApG10xrGnDV2rtKpvFyYYU=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260527160159-3b7634c713b8/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260528134852-3e368bd18814 h1:UZK0qmB7LAe2IZY6RASlsE0Mm1Rjasddd2T5zWWvT54=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260528134852-3e368bd18814/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
<meta name="theme-color">
|
||||
<meta name="application-name" content="Bluesky">
|
||||
<meta name="generator" content="bskyweb">
|
||||
{% if noindex %}<meta name="robots" content="noindex{% if nofollow %}, nofollow{% endif %}">{% endif %}
|
||||
<meta property="og:site_name" content="Bluesky Social">
|
||||
<meta property="og:logo" content="{{ favicon }}">
|
||||
<meta name="twitter:site" content="@bluesky" />
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block html_head_extra -%}
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
{% endif -%}
|
||||
{%- if imgThumbUrl %}
|
||||
<meta property="og:image" content="{{ imgThumbUrl }}">
|
||||
<meta property="twitter:image" content="{{ imgThumbUrl }}">
|
||||
{%- else -%}
|
||||
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
{% endif -%}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{%- if title %}
|
||||
<meta property="og:title" content="{{ title }}" />
|
||||
<meta name="twitter:title" content="{{ title }}" />
|
||||
{%- else -%}
|
||||
<meta property="og:title" content="Bluesky" />
|
||||
<meta name="twitter:title" content="Bluesky" />
|
||||
{% endif -%}
|
||||
<meta name="description" content="Join this group chat on Bluesky" />
|
||||
<meta name="og:description" content="Join this group chat on Bluesky" />
|
||||
<meta name="twitter:description" content="Join this group chat on Bluesky" />
|
||||
{%- endblock %}
|
||||
+1865
-14
File diff suppressed because it is too large
Load Diff
+18
-20
@@ -134,11 +134,10 @@ export default defineConfig(
|
||||
'react-native/no-inline-styles': 'off',
|
||||
...reactNativeA11y.configs.all.rules,
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
// TODO: Fix these and set to error
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
'react-hooks/refs': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'error',
|
||||
'react-hooks/purity': 'error',
|
||||
'react-hooks/refs': 'error',
|
||||
'react-hooks/immutability': 'error',
|
||||
|
||||
/**
|
||||
* Import sorting
|
||||
@@ -235,9 +234,8 @@ export default defineConfig(
|
||||
},
|
||||
],
|
||||
/**
|
||||
* Maintain previous behavior - these are stricter in typescript-eslint
|
||||
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
|
||||
* nit-picky
|
||||
* Maintain previous behavior via eslint-suppressions.json - these are
|
||||
* stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
|
||||
*/
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
@@ -247,18 +245,18 @@ export default defineConfig(
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-misused-promises': 'warn',
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
|
||||
'@typescript-eslint/await-thenable': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/require-await': 'error',
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'error',
|
||||
'@typescript-eslint/no-duplicate-type-constituents': 'error',
|
||||
'@typescript-eslint/no-base-to-string': 'error',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'error',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
|
||||
+19
-14
@@ -41,11 +41,14 @@ class BottomSheetView(
|
||||
private val screenHeight: Float =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
||||
context.resources.displayMetrics.heightPixels.toFloat()
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
wm.currentWindowMetrics.bounds.height().toFloat()
|
||||
wm.currentWindowMetrics.bounds
|
||||
.height()
|
||||
.toFloat()
|
||||
} else {
|
||||
// API < 30: currentWindowMetrics not available, use getRealSize
|
||||
// which includes system bars (heightPixels may exclude them)
|
||||
@@ -166,6 +169,7 @@ class BottomSheetView(
|
||||
when {
|
||||
// Full height sheets
|
||||
contentHeight >= screenHeight -> 0.99f
|
||||
|
||||
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
|
||||
}
|
||||
|
||||
@@ -271,8 +275,9 @@ class BottomSheetView(
|
||||
}
|
||||
// Apply deferred layout update after gesture completes
|
||||
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
|
||||
newState != BottomSheetBehavior.STATE_SETTLING &&
|
||||
pendingLayoutUpdate) {
|
||||
newState != BottomSheetBehavior.STATE_SETTLING &&
|
||||
pendingLayoutUpdate
|
||||
) {
|
||||
pendingLayoutUpdate = false
|
||||
updateLayout()
|
||||
}
|
||||
@@ -292,7 +297,6 @@ class BottomSheetView(
|
||||
if (!fullHeight) {
|
||||
this.startObservingContentHeight()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun updateLayout() {
|
||||
@@ -365,17 +369,18 @@ class BottomSheetView(
|
||||
|
||||
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
||||
|
||||
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||
val newHeight = bottom - top
|
||||
val oldHeight = oldBottom - oldTop
|
||||
if (newHeight != oldHeight) {
|
||||
val contentHeight = getContentHeight()
|
||||
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||
lastObservedContentHeight = contentHeight
|
||||
updateLayout()
|
||||
val listener =
|
||||
OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||
val newHeight = bottom - top
|
||||
val oldHeight = oldBottom - oldTop
|
||||
if (newHeight != oldHeight) {
|
||||
val contentHeight = getContentHeight()
|
||||
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||
lastObservedContentHeight = contentHeight
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val children = mutableListOf<View>()
|
||||
for (i in 0 until innerViewGroup.childCount) {
|
||||
|
||||
@@ -67,7 +67,18 @@ export class GifView extends PureComponent<GifViewProps> {
|
||||
}
|
||||
|
||||
async playAsync(): Promise<void> {
|
||||
this.videoPlayerRef.current?.play()
|
||||
try {
|
||||
await this.videoPlayerRef.current?.play()
|
||||
} catch (err) {
|
||||
// `play()` rejects with a NotAllowedError when the browser blocks
|
||||
// playback (e.g. Safari low-power mode or autoplay policy). This is
|
||||
// expected and benign - the GIF simply stays paused - so swallow it
|
||||
// rather than letting it surface as an unhandled rejection.
|
||||
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async pauseAsync(): Promise<void> {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {type StyleProp, type ViewStyle} from 'react-native'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {VisibilityViewProps} from './types'
|
||||
import {type VisibilityViewProps} from './types'
|
||||
const NativeView: React.ComponentType<{
|
||||
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
|
||||
children: React.ReactNode
|
||||
enabled: Boolean
|
||||
enabled: boolean
|
||||
style: StyleProp<ViewStyle>
|
||||
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
|
||||
|
||||
|
||||
+66
-24
@@ -6,6 +6,7 @@ import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
@@ -13,6 +14,8 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URLEncoder
|
||||
|
||||
private const val TAG = "ExpoReceiveAndroidIntents"
|
||||
|
||||
enum class AttachmentType {
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
@@ -119,17 +122,15 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
uris: List<Uri>,
|
||||
text: String?,
|
||||
) {
|
||||
var allParams = ""
|
||||
// Some URIs we receive may be unreadable (revoked permission, deleted file,
|
||||
// a provider that rejects the read). Skip those rather than crashing the
|
||||
// whole app, since this runs synchronously on the module init path.
|
||||
val allParams =
|
||||
uris
|
||||
.mapNotNull { uri -> getImageInfo(uri) }
|
||||
.joinToString(",") { info -> buildUriData(info) }
|
||||
|
||||
uris.forEachIndexed { index, uri ->
|
||||
val info = getImageInfo(uri)
|
||||
val params = buildUriData(info)
|
||||
allParams = "${allParams}$params"
|
||||
|
||||
if (index < uris.count() - 1) {
|
||||
allParams = "$allParams,"
|
||||
}
|
||||
}
|
||||
if (allParams.isEmpty()) return
|
||||
|
||||
val encodedUris = URLEncoder.encode(allParams, "UTF-8")
|
||||
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
|
||||
@@ -158,12 +159,30 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
}
|
||||
val file = createFile(extension)
|
||||
|
||||
val out = FileOutputStream(file)
|
||||
appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use {
|
||||
it.copyTo(out)
|
||||
// The URI may be unreadable (revoked permission, deleted file, or a
|
||||
// provider that rejects the read). Bail rather than crashing the whole
|
||||
// app, since this runs synchronously on the module init path.
|
||||
try {
|
||||
FileOutputStream(file).use { out ->
|
||||
val input =
|
||||
appContext.currentActivity?.contentResolver?.openInputStream(uri)
|
||||
?: run {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
input.use { it.copyTo(out) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to copy shared video to cache", e)
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
|
||||
val info = getVideoInfo(uri) ?: return
|
||||
val info =
|
||||
getVideoInfo(uri) ?: run {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
|
||||
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
|
||||
|
||||
@@ -176,15 +195,29 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getImageInfo(uri: Uri): Map<String, Any> {
|
||||
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
|
||||
private fun getImageInfo(uri: Uri): Map<String, Any>? {
|
||||
val bitmap =
|
||||
try {
|
||||
MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
|
||||
} catch (e: Exception) {
|
||||
// The URI may be unreadable (revoked permission, deleted file, or a
|
||||
// provider that rejects the read). Skip this image rather than crash.
|
||||
Log.w(TAG, "Failed to read shared image", e)
|
||||
return null
|
||||
} ?: return null
|
||||
// We have to save this so that we can access it later when uploading the image.
|
||||
// createTempFile will automatically place a unique string between "img" and "temp.jpeg"
|
||||
val file = createFile("jpeg")
|
||||
val out = FileOutputStream(file)
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||
out.flush()
|
||||
out.close()
|
||||
try {
|
||||
FileOutputStream(file).use { out ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||
out.flush()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to write shared image to cache", e)
|
||||
file.delete()
|
||||
return null
|
||||
}
|
||||
|
||||
return mapOf(
|
||||
"width" to bitmap.width,
|
||||
@@ -195,10 +228,19 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
|
||||
private fun getVideoInfo(uri: Uri): Map<String, Any>? {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
retriever.setDataSource(appContext.currentActivity, uri)
|
||||
|
||||
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
|
||||
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
||||
val width: Int?
|
||||
val height: Int?
|
||||
try {
|
||||
retriever.setDataSource(appContext.currentActivity, uri)
|
||||
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
|
||||
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
||||
} catch (e: Exception) {
|
||||
// The URI may be unreadable or not a valid media source. Skip rather than crash.
|
||||
Log.w(TAG, "Failed to read shared video metadata", e)
|
||||
return null
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
|
||||
if (width == null || height == null) {
|
||||
return null
|
||||
|
||||
+13
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.123.0",
|
||||
"version": "1.124.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
@@ -8,7 +8,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.1.3",
|
||||
"version": "11.5.2",
|
||||
"onFail": "warn"
|
||||
},
|
||||
"runtime": {
|
||||
@@ -59,7 +59,7 @@
|
||||
"test-watch": "NODE_ENV=test jest --watchAll",
|
||||
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"test-coverage": "NODE_ENV=test jest --coverage",
|
||||
"lint": "eslint --cache --quiet src",
|
||||
"lint": "eslint --cache --quiet src modules",
|
||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||
"typecheck": "tsgo --project ./tsconfig.check.json",
|
||||
@@ -89,18 +89,21 @@
|
||||
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
|
||||
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
"icons:optimize": "svgo -f ./assets/icons",
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.6",
|
||||
"@atproto/api": "0.20.11",
|
||||
"@atproto/syntax": "0.6.1",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.14",
|
||||
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
|
||||
"@bsky.app/expo-guess-language": "^0.2.8",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
"@bsky.app/peek-menu": "^0.2.4",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@bsky.app/sift": "^0.3.8",
|
||||
"@bsky.app/tapper": "^0.5.7",
|
||||
@@ -112,17 +115,15 @@
|
||||
"@expo/webpack-config": "^19.0.1",
|
||||
"@floating-ui/dom": "^1.6.3",
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
"@formatjs/intl-displaynames": "^6.8.13",
|
||||
"@formatjs/intl-locale": "^4.2.13",
|
||||
"@formatjs/intl-numberformat": "^8.15.6",
|
||||
"@formatjs/intl-pluralrules": "^5.4.6",
|
||||
"@formatjs/intl-displaynames": "^7.3.9",
|
||||
"@formatjs/intl-locale": "^5.3.8",
|
||||
"@formatjs/intl-numberformat": "^9.3.10",
|
||||
"@formatjs/intl-pluralrules": "^6.3.9",
|
||||
"@growthbook/growthbook": "^1.6.5",
|
||||
"@growthbook/growthbook-react": "^1.6.5",
|
||||
"@ipld/dag-cbor": "^9.2.7",
|
||||
"@lingui/core": "^5.9.2",
|
||||
"@lingui/react": "^5.9.2",
|
||||
"@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.15.5",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
@@ -150,7 +151,7 @@
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"bcp-47": "^2.1.0",
|
||||
"bcp-47-match": "^2.0.3",
|
||||
"date-fns": "^2.30.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"email-validator": "^2.0.4",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
|
||||
@@ -13,3 +13,39 @@ index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8
|
||||
// Check for Content-Type
|
||||
val skipContentTypes = listOf(
|
||||
"text/event-stream", // Server Sent Events
|
||||
diff --git a/ios/Core/ExpoBridgeModule.mm b/ios/Core/ExpoBridgeModule.mm
|
||||
index 2ed1c00f47406e109750cc27ace7e0d88e42c00e..99d0d140eddf95a8db7beb57c61dfcb12c1424b4 100644
|
||||
--- a/ios/Core/ExpoBridgeModule.mm
|
||||
+++ b/ios/Core/ExpoBridgeModule.mm
|
||||
@@ -7,6 +7,9 @@
|
||||
// The runtime executor is included as of React Native 0.74 in bridgeless mode.
|
||||
#if __has_include(<ReactCommon/RCTRuntimeExecutor.h>)
|
||||
#import <ReactCommon/RCTRuntimeExecutor.h>
|
||||
+#else // React Native <0.74
|
||||
+// dispatchBlock:queue: is declared in RCTBridge+Private.h, not the public header.
|
||||
+#import <React/RCTBridge+Private.h>
|
||||
#endif // React Native >=0.74
|
||||
|
||||
@implementation ExpoBridgeModule
|
||||
@@ -46,7 +49,20 @@ - (void)setBridge:(RCTBridge *)bridge
|
||||
_appContext.reactBridge = bridge;
|
||||
|
||||
#if !__has_include(<ReactCommon/RCTRuntimeExecutor.h>)
|
||||
- _appContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:bridge];
|
||||
+ // Hop the runtime install (and the prepareRuntime() chain it triggers via
|
||||
+ // _runtime.didSet) onto RCTJSThread. The original line ran synchronously
|
||||
+ // on whatever thread called setBridge: - typically the main thread - and
|
||||
+ // raced JSIExecutor::initializeRuntime() on the JS thread, corrupting
|
||||
+ // Hermes' Hades GC (HadesGC::writeBarrierSlow EXC_BAD_ACCESS).
|
||||
+ __weak EXAppContext *weakAppContext = _appContext;
|
||||
+ __weak RCTBridge *weakBridge = bridge;
|
||||
+ [bridge dispatchBlock:^{
|
||||
+ EXAppContext *strongAppContext = weakAppContext;
|
||||
+ RCTBridge *strongBridge = weakBridge;
|
||||
+ if (strongAppContext != nil && strongBridge != nil && strongAppContext._runtime == nil) {
|
||||
+ strongAppContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:strongBridge];
|
||||
+ }
|
||||
+ } queue:RCTJSThread];
|
||||
#endif // React Native <0.74
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
## expo-modules-core Patch
|
||||
|
||||
This patch fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools
|
||||
This patch contains two unrelated fixes:
|
||||
|
||||
### Android: bitdrift interceptor
|
||||
|
||||
Fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools.
|
||||
|
||||
### iOS: Hermes startup race in `ExpoBridgeModule.setBridge:`
|
||||
|
||||
On the legacy bridge (old architecture, where `RCTRuntimeExecutor.h` is
|
||||
absent), `setBridge:` installed the Expo runtime synchronously on whatever
|
||||
thread called it - typically the main thread, since RN's lazy module-load
|
||||
path ignores `+requiresMainQueueSetup`. The `_runtime.didSet` then ran
|
||||
`prepareRuntime()` (JSI mutations) on the main thread while the JS thread was
|
||||
concurrently inside `JSIExecutor::initializeRuntime()`. Two threads mutating
|
||||
the same Hermes runtime corrupted Hades GC, producing intermittent
|
||||
`EXC_BAD_ACCESS` launch crashes (e.g. `HadesGC::writeBarrierSlow`,
|
||||
`prepareRuntime` / `bindNativePerformanceNow`).
|
||||
|
||||
The fix hops the runtime install onto `RCTJSThread` so all JSI mutation is
|
||||
serialized on the JS thread. This backports the upstream fix discussed in
|
||||
expo/expo#45374; the racy `ExpoBridgeModule` is removed entirely in SDK 55+
|
||||
(expo/expo#44351), so this patch can be dropped on that upgrade.
|
||||
|
||||
Refs: expo/expo#43003, expo/expo#45374, expo/expo#44351
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da84061fd7f77c 100644
|
||||
index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1a01093e7909973cef5268f999158df389e77634 100644
|
||||
--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
+++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
@@ -1,8 +1,6 @@
|
||||
@@ -19,7 +19,7 @@ index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da8406
|
||||
inverted,
|
||||
keyboardLiftBehavior,
|
||||
freeze,
|
||||
@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
|
||||
@@ -62,20 +59,23 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
|
||||
(target: number) => {
|
||||
"worklet";
|
||||
|
||||
@@ -37,6 +37,15 @@ index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da8406
|
||||
+ // otherwise the native ScrollView clamps contentOffset to the old
|
||||
+ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android).
|
||||
+ requestAnimationFrame(() => {
|
||||
+ // The ScrollView can detach between scheduling this frame and now (e.g.
|
||||
+ // navigating away from the screen, or the list re-creating its scroll
|
||||
+ // component). Once detached, scrollViewRef() resolves to null, and on
|
||||
+ // Paper reanimated's scrollTo hands that null to the native
|
||||
+ // _scrollToPaper HostFunction, which throws "Value is null, expected a
|
||||
+ // number". Re-check the ref now that the frame has arrived.
|
||||
+ if (scrollViewRef() == null) {
|
||||
+ return;
|
||||
+ }
|
||||
scrollTo(scrollViewRef, 0, target, false);
|
||||
- }
|
||||
+ });
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
diff --git a/lib/commonjs/toast.js b/lib/commonjs/toast.js
|
||||
index 121816a452339c1088aeba87928ff63a0bdacca5..47e74bce47323201f0bb4e5ed9dc55b373c07b97 100644
|
||||
--- a/lib/commonjs/toast.js
|
||||
+++ b/lib/commonjs/toast.js
|
||||
@@ -264,7 +264,7 @@ const Toast = exports.Toast = /*#__PURE__*/React.forwardRef(({
|
||||
diff --git a/lib/module/toast.js b/lib/module/toast.js
|
||||
index be089f3ff23017a6844e2010cedc547729e198aa..c2dd0fa2df93f929c33bf2bcc3e6f921941fa006 100644
|
||||
--- a/lib/module/toast.js
|
||||
+++ b/lib/module/toast.js
|
||||
@@ -1,8 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
import * as React from 'react';
|
||||
-import { ActivityIndicator, Pressable, Text, View } from 'react-native';
|
||||
-import Animated, { useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
|
||||
+import { ActivityIndicator, Pressable, Text, View, Platform } from 'react-native';
|
||||
+import Animated, { useAnimatedStyle, useSharedValue, withRepeat, withTiming, FadeOut } from 'react-native-reanimated';
|
||||
import { ANIMATION_DURATION, useToastLayoutAnimations } from "./animations.js";
|
||||
import { toastDefaultValues } from "./constants.js";
|
||||
import { useToastContext } from "./context.js";
|
||||
@@ -258,7 +258,10 @@ export const Toast = /*#__PURE__*/React.forwardRef(({
|
||||
...toastSwipeHandlerProps,
|
||||
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
|
||||
children: /*#__PURE__*/_jsx(Animated.View, {
|
||||
entering: entering,
|
||||
- exiting: exiting,
|
||||
+ exiting: _reactNative.Platform.OS === 'android' ? undefined : exiting,
|
||||
+ exiting: Platform.select({
|
||||
+ android: undefined,
|
||||
+ default: exiting
|
||||
+ }),
|
||||
children: jsx
|
||||
})
|
||||
});
|
||||
@@ -274,7 +274,7 @@ const Toast = exports.Toast = /*#__PURE__*/React.forwardRef(({
|
||||
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
|
||||
@@ -268,7 +271,10 @@ export const Toast = /*#__PURE__*/React.forwardRef(({
|
||||
children: /*#__PURE__*/_jsx(Animated.View, {
|
||||
style: [unstyled ? undefined : elevationStyle, defaultStyles.toast, toastStyleCtx, styles?.toast, style, wiggleAnimationStyle],
|
||||
entering: entering,
|
||||
- exiting: exiting,
|
||||
+ exiting: _reactNative.Platform.OS === 'android' ? undefined : exiting,
|
||||
children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
|
||||
+ exiting: Platform.select({
|
||||
+ android: undefined,
|
||||
+ default: exiting
|
||||
+ }),
|
||||
children: /*#__PURE__*/_jsxs(View, {
|
||||
style: [defaultStyles.toastContent, toastContentStyleCtx, styles?.toastContent],
|
||||
children: [promiseOptions || variant === 'loading' ? 'loading' in icons ? icons.loading : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ActivityIndicator, {}) : icon ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
||||
children: [promiseOptions || variant === 'loading' ? 'loading' in icons ? icons.loading : /*#__PURE__*/_jsx(ActivityIndicator, {}) : icon ? /*#__PURE__*/_jsx(View, {
|
||||
|
||||
Generated
+137
-157
@@ -7,52 +7,52 @@ importers:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
'@pnpm/exe':
|
||||
specifier: 11.1.3
|
||||
version: 11.1.3
|
||||
specifier: 11.5.2
|
||||
version: 11.5.2
|
||||
pnpm:
|
||||
specifier: 11.1.3
|
||||
version: 11.1.3
|
||||
specifier: 11.5.2
|
||||
version: 11.5.2
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe@11.1.3':
|
||||
resolution: {integrity: sha512-J6bSMpZlHVUKuMKtPT/+lrFPpvBiOIgk6HnNC3vmoM/fsMgFhao6ITFwdsUMzdCl2qHf9cnVYA4ZBdsGmVUnpg==}
|
||||
'@pnpm/exe@11.5.2':
|
||||
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/linux-arm64@11.1.3':
|
||||
resolution: {integrity: sha512-sz3fc0hSqguk2eGe/InelpBD3LP82MzF+8pLzDpYNGuhasGY+VWkuxEo02HczQYRVXTsbpZVepk+Qs2CONc04Q==}
|
||||
'@pnpm/linux-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linux-x64@11.1.3':
|
||||
resolution: {integrity: sha512-UadJh5fJZWa47OtdZTWLKWDj4z5WpZFB5pS2wOh/kfKypUmQyjmbOMClP7/yJGS2ZtrSjRgYjIEWAjndkWsMaA==}
|
||||
'@pnpm/linux-x64@11.5.2':
|
||||
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.1.3':
|
||||
resolution: {integrity: sha512-lWmGr96w+VrIRVsEfTXROB3GeQNxrX2Hy32j3USHr6WlqmpH1i7YjavJGpoXeVZbb77fCFjNFAtsJzGXSgy/Og==}
|
||||
'@pnpm/linuxstatic-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.1.3':
|
||||
resolution: {integrity: sha512-I74GDBOPbr5TXgob3ct4hv6BQtLGdsjGJrII2qNl/e2xYHXekjIWE4Eh7RGN4C7xu+BZYKtvnOOr149yR7KxUw==}
|
||||
'@pnpm/linuxstatic-x64@11.5.2':
|
||||
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/macos-arm64@11.1.3':
|
||||
resolution: {integrity: sha512-nWn155BVa54iNyg4iolVhjMtqumXHPh8ul5CFjQFJXdwgr9MRUgc5EUBML1+7mS8C/7Wcex5HUgn/w3fliHCsQ==}
|
||||
'@pnpm/macos-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/win-arm64@11.1.3':
|
||||
resolution: {integrity: sha512-4u6PQL7/WgwdweC2ZJcdCzhM1koRFG5ofcUSrIVsdyo9ePfGq2D9p1rkZbiLEfIGvl3GKOsfMB5DRTgs8es4AQ==}
|
||||
'@pnpm/win-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/win-x64@11.1.3':
|
||||
resolution: {integrity: sha512-JmhH7ljJ3MWjvWFz/YHbu/27ISCNLZsQb1JG+ib3uHlfUwIJuF6BudXYsbS5Uu0o7xUvC9sPPm+Uho4Or6R3vA==}
|
||||
'@pnpm/win-x64@11.5.2':
|
||||
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -116,45 +116,45 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pnpm@11.1.3:
|
||||
resolution: {integrity: sha512-yFNX/hfKEt0j3XBxgiZm39fjy3b+IU4zcLXqL7NPKiMRhVCbY+cX880KyzjdP42CvNXoFyQArmeLcOpPvtCJbQ==}
|
||||
pnpm@11.5.2:
|
||||
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
|
||||
engines: {node: '>=22.13'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe@11.1.3':
|
||||
'@pnpm/exe@11.5.2':
|
||||
dependencies:
|
||||
'@reflink/reflink': 0.1.19
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@pnpm/linux-arm64': 11.1.3
|
||||
'@pnpm/linux-x64': 11.1.3
|
||||
'@pnpm/linuxstatic-arm64': 11.1.3
|
||||
'@pnpm/linuxstatic-x64': 11.1.3
|
||||
'@pnpm/macos-arm64': 11.1.3
|
||||
'@pnpm/win-arm64': 11.1.3
|
||||
'@pnpm/win-x64': 11.1.3
|
||||
'@pnpm/linux-arm64': 11.5.2
|
||||
'@pnpm/linux-x64': 11.5.2
|
||||
'@pnpm/linuxstatic-arm64': 11.5.2
|
||||
'@pnpm/linuxstatic-x64': 11.5.2
|
||||
'@pnpm/macos-arm64': 11.5.2
|
||||
'@pnpm/win-arm64': 11.5.2
|
||||
'@pnpm/win-x64': 11.5.2
|
||||
|
||||
'@pnpm/linux-arm64@11.1.3':
|
||||
'@pnpm/linux-arm64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linux-x64@11.1.3':
|
||||
'@pnpm/linux-x64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.1.3':
|
||||
'@pnpm/linuxstatic-arm64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.1.3':
|
||||
'@pnpm/linuxstatic-x64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/macos-arm64@11.1.3':
|
||||
'@pnpm/macos-arm64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-arm64@11.1.3':
|
||||
'@pnpm/win-arm64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-x64@11.1.3':
|
||||
'@pnpm/win-x64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
@@ -194,7 +194,7 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
pnpm@11.1.3: {}
|
||||
pnpm@11.5.2: {}
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
@@ -222,28 +222,28 @@ patchedDependencies:
|
||||
expo-image-picker@17.0.11: 47b28f6ddb8bcaa6483f8714c82daaa0001122f2b0dac6c05d19e98a61a6ceab
|
||||
expo-image@3.0.11: 6d46ffac33426c5285777da5bf5f88c68c8ce27290595dfa65f38c478930c2c4
|
||||
expo-media-library@18.2.1: 2b84c17999bb0c977eaef7fa8ac297f7074d91c94fb63726b42d2a277b26b39a
|
||||
expo-modules-core@3.0.30: 86e57bb33bc6c3f7021e948e099c2b5378772147b92a82a16e2e90ddcb857ace
|
||||
expo-modules-core@3.0.30: 952fb1c8cb6dc8a0d8ef4e1114942a341d89b025e011f1acfff788929997346e
|
||||
expo-notifications@0.32.17: a45a8dcf3d8c4b5df4ee0e62bc79b755fcf9d28ca51a6f685b85830bf4afc2e5
|
||||
expo-updates@29.0.17: 04f28cb005b770e9ae8f0065eab96e43cbb1e58107f5f6ad1bdd18f6deb66487
|
||||
react-native-compressor@1.13.0: 58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925
|
||||
react-native-date-picker@5.0.13: f2a6697da7a7ca79b4f39efda142eee1b82db6de3aa5010ad4bd59df41fe2ce1
|
||||
react-native-drawer-layout@4.2.3: 74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130
|
||||
react-native-keyboard-controller@1.21.8: 642d128c971380a1858f7a938dd715d6eb3bbc260c93188b65580d92e2226afe
|
||||
react-native-keyboard-controller@1.21.8: 2f6791837622fb391c3e41fc044b91ddedbe503da14da86165ee0a73924b05f1
|
||||
react-native-pager-view@6.8.0: 0979d6fe1e2fa43b2784cc0b5e0ff0117d53b53423e85ee5855eefdc41a00108
|
||||
react-native-reanimated@3.19.1: 97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6
|
||||
react-native-svg@15.12.1: 31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9
|
||||
react-native-uitextview@1.4.0: 62de26caadfc39d1bdff204cdc03a705c0cd343999825e06d8c0c120de06cc99
|
||||
react-native-view-shot@4.0.3: a2457dc30a82cc8c21f371a6f860d9396d450a2a1b4265b1a4ee13bdb24d3268
|
||||
react-native@0.81.5: 2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194
|
||||
sonner-native@0.21.0: a07ea00b9f97634a402875e49a5231407e82733dd49c5dbc230afc3d1bc7dcf5
|
||||
sonner-native@0.21.0: 4bcd0da0fec32e943460e6e788a9e4adf601b507d2dfd0c8246a6c8c74d8f638
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.6
|
||||
version: 0.20.6
|
||||
specifier: 0.20.11
|
||||
version: 0.20.11
|
||||
'@atproto/syntax':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
@@ -256,6 +256,9 @@ importers:
|
||||
'@bsky.app/alf':
|
||||
specifier: ^0.1.14
|
||||
version: 0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/expo-dynamic-app-icon':
|
||||
specifier: ^1.8.5
|
||||
version: 1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/expo-guess-language':
|
||||
specifier: ^0.2.8
|
||||
version: 0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -268,6 +271,9 @@ importers:
|
||||
'@bsky.app/expo-translate-text':
|
||||
specifier: ^0.2.9
|
||||
version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/peek-menu':
|
||||
specifier: ^0.2.4
|
||||
version: 0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/react-native-mmkv':
|
||||
specifier: 2.12.5
|
||||
version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -302,17 +308,17 @@ importers:
|
||||
specifier: ^2.0.8
|
||||
version: 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@formatjs/intl-displaynames':
|
||||
specifier: ^6.8.13
|
||||
version: 6.8.13
|
||||
specifier: ^7.3.9
|
||||
version: 7.3.9
|
||||
'@formatjs/intl-locale':
|
||||
specifier: ^4.2.13
|
||||
version: 4.2.13
|
||||
specifier: ^5.3.8
|
||||
version: 5.3.8
|
||||
'@formatjs/intl-numberformat':
|
||||
specifier: ^8.15.6
|
||||
version: 8.15.6
|
||||
specifier: ^9.3.10
|
||||
version: 9.3.10
|
||||
'@formatjs/intl-pluralrules':
|
||||
specifier: ^5.4.6
|
||||
version: 5.4.6
|
||||
specifier: ^6.3.9
|
||||
version: 6.3.9
|
||||
'@growthbook/growthbook':
|
||||
specifier: ^1.6.5
|
||||
version: 1.6.5
|
||||
@@ -328,12 +334,6 @@ importers:
|
||||
'@lingui/react':
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
|
||||
'@miblanchard/react-native-slider':
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@mozzius/expo-dynamic-app-icon':
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@react-native-async-storage/async-storage':
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
|
||||
@@ -416,8 +416,8 @@ importers:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
date-fns:
|
||||
specifier: ^2.30.0
|
||||
version: 2.30.0
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
email-validator:
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
@@ -633,7 +633,7 @@ importers:
|
||||
version: 2.28.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-keyboard-controller:
|
||||
specifier: ^1.21.8
|
||||
version: 1.21.8(patch_hash=642d128c971380a1858f7a938dd715d6eb3bbc260c93188b65580d92e2226afe)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
version: 1.21.8(patch_hash=2f6791837622fb391c3e41fc044b91ddedbe503da14da86165ee0a73924b05f1)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-pager-view:
|
||||
specifier: 6.8.0
|
||||
version: 6.8.0(patch_hash=0979d6fe1e2fa43b2784cc0b5e0ff0117d53b53423e85ee5855eefdc41a00108)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -693,7 +693,7 @@ importers:
|
||||
version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
sonner-native:
|
||||
specifier: 0.21.0
|
||||
version: 0.21.0(patch_hash=a07ea00b9f97634a402875e49a5231407e82733dd49c5dbc230afc3d1bc7dcf5)(4e42d15c94c2c56af69d343a007eb893)
|
||||
version: 0.21.0(patch_hash=4bcd0da0fec32e943460e6e788a9e4adf601b507d2dfd0c8246a6c8c74d8f638)(4e42d15c94c2c56af69d343a007eb893)
|
||||
tippy.js:
|
||||
specifier: ^6.3.7
|
||||
version: 6.3.7
|
||||
@@ -877,8 +877,8 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@atproto/api@0.20.6':
|
||||
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
|
||||
'@atproto/api@0.20.11':
|
||||
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -1624,6 +1624,13 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky.app/expo-dynamic-app-icon@1.8.5':
|
||||
resolution: {integrity: sha512-yLpd7XEEiXWpVrh81mhpZx2WrX2WwrJFEV81lNxnX61Cp6yG+ZKX3Oz1v58vpyL5p0I38njVUJ9s+n5NR4EjNw==}
|
||||
peerDependencies:
|
||||
expo: ^52 || ^53 || ^54
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky.app/expo-guess-language@0.2.8':
|
||||
resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==}
|
||||
peerDependencies:
|
||||
@@ -1652,6 +1659,13 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky.app/peek-menu@0.2.4':
|
||||
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky.app/react-native-mmkv@2.12.5':
|
||||
resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==}
|
||||
peerDependencies:
|
||||
@@ -2065,33 +2079,32 @@ packages:
|
||||
'@floating-ui/utils@0.2.11':
|
||||
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||
|
||||
'@formatjs/ecma402-abstract@2.3.6':
|
||||
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
|
||||
'@formatjs/bigdecimal@0.2.5':
|
||||
resolution: {integrity: sha512-2XTKNrZRaCUyXK2976wfutqxMBuPO/S/zbJnQdysLI2Zy5mWPVNVEkE6tsTcSVWSE7DgO88t8DtBy+uf3I8bxg==}
|
||||
|
||||
'@formatjs/fast-memoize@2.2.7':
|
||||
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
|
||||
'@formatjs/fast-memoize@3.1.5':
|
||||
resolution: {integrity: sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==}
|
||||
|
||||
'@formatjs/intl-displaynames@6.8.13':
|
||||
resolution: {integrity: sha512-VbY7BdYJX5eURVKLk2grndUQtnbCLNbcJId/Sb/PsX7fWXiqWvg7qt/mecVHRzqoSEoGCQToKDxzpJj8RC0s3g==}
|
||||
'@formatjs/intl-displaynames@7.3.9':
|
||||
resolution: {integrity: sha512-UELvrHS171ElQW9iMCGqMR/HmUYQPYkYSz1A/SSkQtJ62ZH7aUWC1QnXPxL0CInQmxa/MgC7oE402XBoTe5nTQ==}
|
||||
|
||||
'@formatjs/intl-enumerator@1.8.12':
|
||||
resolution: {integrity: sha512-rT2hnyQ0q6RDG1jISMH+MP6wlHQLUC81C0BeYEzabJb48S4tU2HGCPUxsOqMa3vDAUmpnz9Kw5vYTD5hTkOVgg==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
'@formatjs/intl-getcanonicallocales@3.2.9':
|
||||
resolution: {integrity: sha512-b1dLzhFSeccG5X1PY2KodVFRGd0p6tBPc7pRBJaW6A7+fMeZnwHOAJLkMsGNDyvM9XailkkstE4jzmNa1GVh5w==}
|
||||
|
||||
'@formatjs/intl-getcanonicallocales@2.5.6':
|
||||
resolution: {integrity: sha512-CnBbc4St61RL06gDXlCZG08Gt41uiySgsdZNBExh8/c0FBONJCrAlQ9FsyalUdq9ze0nCeknJtMmO8JnB9xHgQ==}
|
||||
'@formatjs/intl-locale@5.3.8':
|
||||
resolution: {integrity: sha512-pJgqQlg4zFoyCiVMXlKPBWpY7fAxvsUiShNmB/l8BW2ZwmFMefMWCrDzgocSH9WR+Z3pkihfjh1XbalCZhXc1Q==}
|
||||
|
||||
'@formatjs/intl-locale@4.2.13':
|
||||
resolution: {integrity: sha512-Qcs0mP9JhEPCinPfpZ5JKzOa+VYDg94TQSFZozYuqG8R+hEGUTKyoyMqB5xi5QZT/hlB0CSgeNc/0fWMGtTFXA==}
|
||||
'@formatjs/intl-localematcher@0.8.9':
|
||||
resolution: {integrity: sha512-GmB0F/gYh4Hdl4rLWjgDsgT+x4pB54fkJeRh8kAZ4XFzKeCK8dGs+SBJWXO42QZtOUni+IDWKNuCw6wiL4lTvw==}
|
||||
|
||||
'@formatjs/intl-localematcher@0.6.2':
|
||||
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
|
||||
'@formatjs/intl-numberformat@9.3.10':
|
||||
resolution: {integrity: sha512-wYboXVXZyTvuMBgwE0AxcXYxV8LM0IkH+lfCoBlRVNjCwNzdHVbRcUxDRHE+HbFkQ6/zWFkyguvIP5y3PAt+jw==}
|
||||
|
||||
'@formatjs/intl-numberformat@8.15.6':
|
||||
resolution: {integrity: sha512-htynTNKm5WOnbR521tNSMkWzX3yO6Z77qjOxvRujh5/A/UBKeoNElyuKCJltizdx3X33QNWQZC4aWjLKcccyeQ==}
|
||||
'@formatjs/intl-pluralrules@6.3.9':
|
||||
resolution: {integrity: sha512-0405uU1npNs5/N04tfIQ3pme96Xb51tEkCBTBKy8HMjAsssbhnK70gf8v3ogj1WqHf7WFRoFZu8clQTsvwBQNg==}
|
||||
|
||||
'@formatjs/intl-pluralrules@5.4.6':
|
||||
resolution: {integrity: sha512-2HlOq+c7KsSps829SJ3B5987coX5mzKx9NbPcNwQ07eq8FBHgB3HfMoxt5HvLsdk4oQwCjAEnocbtd+wVwZ2Kg==}
|
||||
'@formatjs/intl-supportedvaluesof@2.3.7':
|
||||
resolution: {integrity: sha512-ZTXEKijV5H08z19odngZPsXlmGndfukHuVnjd9qyER77XSk/yJwDMiSjoJYY5C5WxFs2UN4s/EMtu75e6Tp3ug==}
|
||||
|
||||
'@gorhom/portal@1.0.14':
|
||||
resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==}
|
||||
@@ -2321,19 +2334,6 @@ packages:
|
||||
'@messageformat/parser@5.1.1':
|
||||
resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==}
|
||||
|
||||
'@miblanchard/react-native-slider@2.6.0':
|
||||
resolution: {integrity: sha512-o7hk/f/8vkqh6QNR5L52m+ws846fQeD/qNCC9CCSRdBqjq66KiCgbxzlhRzKM/gbtxcvMYMIEEJ1yes5cr6I3A==}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-native: '>=0.59'
|
||||
|
||||
'@mozzius/expo-dynamic-app-icon@1.8.1':
|
||||
resolution: {integrity: sha512-JWNY9gw06s+q54b2SqWf6BEo7IYuJCtjJDtca+wTo6kP5dH/wYK85JdLrMbdy+cwOMScEY85uU6Mjn0wkWTLnw==}
|
||||
peerDependencies:
|
||||
expo: ^52 || ^53 || ^54
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -4789,13 +4789,12 @@ packages:
|
||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
date-fns@2.30.0:
|
||||
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
|
||||
engines: {node: '>=0.11'}
|
||||
|
||||
date-fns@3.6.0:
|
||||
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
|
||||
|
||||
date-fns@4.4.0:
|
||||
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||
|
||||
debounce@1.2.1:
|
||||
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
|
||||
|
||||
@@ -9494,7 +9493,7 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.6':
|
||||
'@atproto/api@0.20.11':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
@@ -10447,6 +10446,14 @@ snapshots:
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
react-responsive: 10.0.1(react@19.1.0)
|
||||
|
||||
'@bsky.app/expo-dynamic-app-icon@1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@expo/image-utils': 0.8.12
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
xcode: 3.0.1
|
||||
|
||||
'@bsky.app/expo-guess-language@0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -10472,6 +10479,12 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
'@bsky.app/peek-menu@0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
'@bsky.app/react-native-mmkv@2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
@@ -11055,56 +11068,38 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.11': {}
|
||||
|
||||
'@formatjs/ecma402-abstract@2.3.6':
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 2.2.7
|
||||
'@formatjs/intl-localematcher': 0.6.2
|
||||
decimal.js: 10.6.0
|
||||
tslib: 2.8.1
|
||||
'@formatjs/bigdecimal@0.2.5': {}
|
||||
|
||||
'@formatjs/fast-memoize@2.2.7':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
'@formatjs/fast-memoize@3.1.5': {}
|
||||
|
||||
'@formatjs/intl-displaynames@6.8.13':
|
||||
'@formatjs/intl-displaynames@7.3.9':
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.6
|
||||
'@formatjs/intl-localematcher': 0.6.2
|
||||
tslib: 2.8.1
|
||||
'@formatjs/intl-localematcher': 0.8.9
|
||||
|
||||
'@formatjs/intl-enumerator@1.8.12':
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.6
|
||||
tslib: 2.8.1
|
||||
'@formatjs/intl-getcanonicallocales@3.2.9': {}
|
||||
|
||||
'@formatjs/intl-getcanonicallocales@2.5.6':
|
||||
'@formatjs/intl-locale@5.3.8':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
'@formatjs/intl-getcanonicallocales': 3.2.9
|
||||
'@formatjs/intl-supportedvaluesof': 2.3.7
|
||||
|
||||
'@formatjs/intl-locale@4.2.13':
|
||||
'@formatjs/intl-localematcher@0.8.9':
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.6
|
||||
'@formatjs/intl-enumerator': 1.8.12
|
||||
'@formatjs/intl-getcanonicallocales': 2.5.6
|
||||
tslib: 2.8.1
|
||||
'@formatjs/fast-memoize': 3.1.5
|
||||
|
||||
'@formatjs/intl-localematcher@0.6.2':
|
||||
'@formatjs/intl-numberformat@9.3.10':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
'@formatjs/bigdecimal': 0.2.5
|
||||
'@formatjs/intl-localematcher': 0.8.9
|
||||
|
||||
'@formatjs/intl-numberformat@8.15.6':
|
||||
'@formatjs/intl-pluralrules@6.3.9':
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.6
|
||||
'@formatjs/intl-localematcher': 0.6.2
|
||||
decimal.js: 10.6.0
|
||||
tslib: 2.8.1
|
||||
'@formatjs/bigdecimal': 0.2.5
|
||||
'@formatjs/intl-localematcher': 0.8.9
|
||||
|
||||
'@formatjs/intl-pluralrules@5.4.6':
|
||||
'@formatjs/intl-supportedvaluesof@2.3.7':
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.6
|
||||
'@formatjs/intl-localematcher': 0.6.2
|
||||
decimal.js: 10.6.0
|
||||
tslib: 2.8.1
|
||||
'@formatjs/fast-memoize': 3.1.5
|
||||
|
||||
'@gorhom/portal@1.0.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
@@ -11462,19 +11457,6 @@ snapshots:
|
||||
dependencies:
|
||||
moo: 0.5.3
|
||||
|
||||
'@miblanchard/react-native-slider@2.6.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
'@mozzius/expo-dynamic-app-icon@1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@expo/image-utils': 0.8.12
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
xcode: 3.0.1
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
@@ -14235,12 +14217,10 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-data-view: 1.0.2
|
||||
|
||||
date-fns@2.30.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
|
||||
date-fns@3.6.0: {}
|
||||
|
||||
date-fns@4.4.0: {}
|
||||
|
||||
debounce@1.2.1: {}
|
||||
|
||||
debug@2.6.9:
|
||||
@@ -15033,7 +15013,7 @@ snapshots:
|
||||
require-from-string: 2.0.2
|
||||
resolve-from: 5.0.0
|
||||
|
||||
expo-modules-core@3.0.30(patch_hash=86e57bb33bc6c3f7021e948e099c2b5378772147b92a82a16e2e90ddcb857ace)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
expo-modules-core@3.0.30(patch_hash=952fb1c8cb6dc8a0d8ef4e1114942a341d89b025e011f1acfff788929997346e)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
invariant: 2.2.4
|
||||
react: 19.1.0
|
||||
@@ -15169,7 +15149,7 @@ snapshots:
|
||||
expo-font: 14.0.11(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
expo-keep-awake: 15.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0)
|
||||
expo-modules-autolinking: 3.0.25
|
||||
expo-modules-core: 3.0.30(patch_hash=86e57bb33bc6c3f7021e948e099c2b5378772147b92a82a16e2e90ddcb857ace)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
expo-modules-core: 3.0.30(patch_hash=952fb1c8cb6dc8a0d8ef4e1114942a341d89b025e011f1acfff788929997346e)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
pretty-format: 29.7.0
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
@@ -17926,7 +17906,7 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
react-native-keyboard-controller@1.21.8(patch_hash=642d128c971380a1858f7a938dd715d6eb3bbc260c93188b65580d92e2226afe)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
react-native-keyboard-controller@1.21.8(patch_hash=2f6791837622fb391c3e41fc044b91ddedbe503da14da86165ee0a73924b05f1)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
@@ -18539,7 +18519,7 @@ snapshots:
|
||||
uuid: 8.3.2
|
||||
websocket-driver: 0.7.4
|
||||
|
||||
sonner-native@0.21.0(patch_hash=a07ea00b9f97634a402875e49a5231407e82733dd49c5dbc230afc3d1bc7dcf5)(4e42d15c94c2c56af69d343a007eb893):
|
||||
sonner-native@0.21.0(patch_hash=4bcd0da0fec32e943460e6e788a9e4adf601b507d2dfd0c8246a6c8c74d8f638)(4e42d15c94c2c56af69d343a007eb893):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
+6
-6
@@ -47,18 +47,18 @@ import {
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||
import {Provider as LandingProvider} from '#/state/shell/landing'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||
import {Shell} from '#/view/shell'
|
||||
import {atoms as a, ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
|
||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||
@@ -113,7 +113,7 @@ function InnerApp() {
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {t: l} = useLingui()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
const hasCheckedLanding = useLandingEntry()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -146,7 +146,7 @@ function InnerApp() {
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ContextMenuProvider>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<Splash isReady={isReady && hasCheckedLanding}>
|
||||
<VideoVolumeProvider>
|
||||
<Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
@@ -246,12 +246,12 @@ function App() {
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<StarterPackProvider>
|
||||
<LandingProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</LandingProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
|
||||
+6
-6
@@ -40,17 +40,17 @@ import {
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||
import {Provider as LandingProvider} from '#/state/shell/landing'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||
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 {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
|
||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||
@@ -92,7 +92,7 @@ function InnerApp() {
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {t: l} = useLingui()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
const hasCheckedLanding = useLandingEntry()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -125,7 +125,7 @@ function InnerApp() {
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ContextMenuProvider>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<Splash isReady={isReady && hasCheckedLanding}>
|
||||
<VideoVolumeProvider>
|
||||
<ActiveVideoProvider>
|
||||
<Fragment
|
||||
@@ -224,9 +224,9 @@ function App() {
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<LandingProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</LandingProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type State,
|
||||
} from '#/lib/routes/types'
|
||||
import {bskyTitle} from '#/lib/strings/headings'
|
||||
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
@@ -81,6 +82,7 @@ import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
|
||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||
import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
|
||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
||||
@@ -487,6 +489,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
getComponent={() => MessagesConversationSettingsScreen}
|
||||
options={{title: title(msg`Group chat settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesJoinRequests"
|
||||
getComponent={() => MessagesJoinRequestsScreen}
|
||||
options={{title: title(msg`Requests to join`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesSettings"
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
@@ -797,6 +804,19 @@ const LINKING = {
|
||||
return buildStateObject('Flat', 'Home', params)
|
||||
}
|
||||
|
||||
// Chat invite URLs (`/chat/:code`) are handled by `useIntentHandler`, which
|
||||
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
|
||||
// path to Home so the dialog overlays Home instead of NotFound. On native,
|
||||
// react-navigation strips the `bluesky://` prefix and passes the path
|
||||
// without a leading slash, so normalize before matching.
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
if (CHAT_INVITE_CODE_REGEX.test(normalizedPath.split('?')[0])) {
|
||||
if (IS_NATIVE) {
|
||||
return buildStateObject('HomeTab', 'Home', params)
|
||||
}
|
||||
return buildStateObject('Flat', 'Home', params)
|
||||
}
|
||||
|
||||
if (IS_NATIVE) {
|
||||
if (name === 'Search') {
|
||||
return buildStateObject('SearchTab', 'Search', params)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const prefetchAgeAssuranceData = () => {}
|
||||
export const prefetchAgeAssuranceServerData = () => {}
|
||||
export const setBirthdateForDid = () => {}
|
||||
export const setCreatedAtForDid = () => {}
|
||||
|
||||
@@ -32,7 +32,7 @@ import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||
import {
|
||||
isLegacyBirthdateBug,
|
||||
@@ -53,7 +53,7 @@ export function NoAccessScreen() {
|
||||
const birthdateControl = useDialogControl()
|
||||
const deactivateAccountControl = useDialogControl()
|
||||
const deleteAccountControl = useDialogControl()
|
||||
const {data} = useAgeAssuranceDataContext()
|
||||
const {metadata} = useAgeAssuranceServerDataContext()
|
||||
const region = useAgeAssuranceRegionConfig()
|
||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
@@ -62,15 +62,15 @@ export function NoAccessScreen() {
|
||||
const aa = useAgeAssurance()
|
||||
const isBlocked = aa.state.status === aa.Status.Blocked
|
||||
const isAARegion = !!region
|
||||
const hasDeclaredAge = data?.declaredAge !== undefined
|
||||
const hasDeclaredAge = metadata?.declaredAge !== undefined
|
||||
const canUpdateBirthday =
|
||||
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
|
||||
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
|
||||
|
||||
useEffect(() => {
|
||||
// just counting overall hits here
|
||||
ax.metric(`blockedGeoOverlay:shown`, {})
|
||||
ax.metric(`ageAssurance:noAccessScreen:shown`, {
|
||||
accountCreatedAt: data?.accountCreatedAt || 'unknown',
|
||||
accountCreatedAt: metadata?.accountCreatedAt || 'unknown',
|
||||
isAARegion,
|
||||
hasDeclaredAge,
|
||||
canUpdateBirthday,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
|
||||
/**
|
||||
* Minimum age required to access the app at all.
|
||||
*/
|
||||
export const MIN_ACCESS_AGE = 13
|
||||
|
||||
export 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
+24
-22
@@ -24,6 +24,7 @@ import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declar
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as debug from '#/ageAssurance/debug'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {type AgeAssuranceMetadata} from '#/ageAssurance/types'
|
||||
import {
|
||||
getBirthdateStringFromAge,
|
||||
isLegacyBirthdateBug,
|
||||
@@ -485,9 +486,9 @@ export function useOtherRequiredDataQuery() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to prefetch all age assurance data.
|
||||
* Helper to prefetch all age assurance data from the server.
|
||||
*/
|
||||
export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
|
||||
export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
|
||||
return Promise.allSettled([
|
||||
// config fetch initiated at the top of the App.platform.tsx files, awaited here
|
||||
configPrefetchPromise,
|
||||
@@ -496,8 +497,8 @@ export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
|
||||
])
|
||||
}
|
||||
|
||||
export function clearAgeAssuranceDataForDid({did}: {did: string}) {
|
||||
logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
|
||||
export function clearAgeAssuranceServerDataForDid({did}: {did: string}) {
|
||||
logger.debug(`clearAgeAssuranceServerDataForDid: ${did}`)
|
||||
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
|
||||
qc.removeQueries({
|
||||
queryKey: createOtherRequiredDataQueryKey({did}),
|
||||
@@ -505,8 +506,8 @@ export function clearAgeAssuranceDataForDid({did}: {did: string}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function clearAgeAssuranceData() {
|
||||
logger.debug(`clearAgeAssuranceData`)
|
||||
export function clearAgeAssuranceServerDataForAll() {
|
||||
logger.debug(`clearAgeAssuranceServerDataForAll`)
|
||||
qc.clear()
|
||||
}
|
||||
|
||||
@@ -514,30 +515,30 @@ export function clearAgeAssuranceData() {
|
||||
* Context
|
||||
*/
|
||||
|
||||
export type AgeAssuranceData = {
|
||||
export type AgeAssuranceServerData = {
|
||||
/**
|
||||
* The raw config from the appview.
|
||||
*/
|
||||
config: AppBskyAgeassuranceDefs.Config | undefined
|
||||
/**
|
||||
* The raw state from the appview. Must be further processed before being useful.
|
||||
*/
|
||||
state: AppBskyAgeassuranceDefs.State | undefined
|
||||
data:
|
||||
| {
|
||||
accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
|
||||
declaredAge: number | undefined
|
||||
birthdate: string | undefined
|
||||
}
|
||||
| undefined
|
||||
metadata: AgeAssuranceMetadata | undefined
|
||||
}
|
||||
export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
|
||||
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||
config: undefined,
|
||||
state: undefined,
|
||||
data: {
|
||||
metadata: {
|
||||
accountCreatedAt: undefined,
|
||||
declaredAge: undefined,
|
||||
birthdate: undefined,
|
||||
},
|
||||
})
|
||||
export function useAgeAssuranceDataContext() {
|
||||
return useContext(AgeAssuranceDataContext)
|
||||
export function useAgeAssuranceServerDataContext() {
|
||||
return useContext(AgeAssuranceServerDataContext)
|
||||
}
|
||||
export function AgeAssuranceDataProvider({
|
||||
export function AgeAssuranceServerDataProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
@@ -550,7 +551,8 @@ export function AgeAssuranceDataProvider({
|
||||
() => ({
|
||||
config,
|
||||
state,
|
||||
data: {
|
||||
metadata: {
|
||||
// yes, it's weird, but accountCreatedAt comes back on the `getState` endpoint
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: data?.birthdate
|
||||
? getAge(new Date(data.birthdate))
|
||||
@@ -561,8 +563,8 @@ export function AgeAssuranceDataProvider({
|
||||
[config, state, data, metadata],
|
||||
)
|
||||
return (
|
||||
<AgeAssuranceDataContext.Provider value={ctx}>
|
||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||
{children}
|
||||
</AgeAssuranceDataContext.Provider>
|
||||
</AgeAssuranceServerDataContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
+213
-28
@@ -26,35 +26,8 @@ export const deviceGeolocation: Geolocation | 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,
|
||||
access: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const otherRequiredData: OtherRequiredData = {
|
||||
birthdate: new Date(2000, 1, 1).toISOString(),
|
||||
birthdate: new Date(2010, 12, 1).toISOString(),
|
||||
}
|
||||
|
||||
const serverStateEnabled = false || IS_E2E
|
||||
@@ -72,6 +45,218 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
regions: [
|
||||
{
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'GB',
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 13,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'AU',
|
||||
minAccessAge: 16,
|
||||
rules: [
|
||||
{
|
||||
date: '2025-12-10T00:00:00Z',
|
||||
access: 'none',
|
||||
$type: ids.IfAccountNewerThan,
|
||||
},
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 16,
|
||||
access: 'safe',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 16,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'SD',
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 13,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'WY',
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 13,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'OH',
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 13,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'MS',
|
||||
minAccessAge: 18,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'VA',
|
||||
minAccessAge: 16,
|
||||
rules: [
|
||||
{
|
||||
age: 16,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 16,
|
||||
access: 'full',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'TN',
|
||||
minAccessAge: 18,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'BR',
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
age: 13,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export async function resolve<T>(data: T) {
|
||||
await new Promise(y => setTimeout(y, 500)) // simulate network
|
||||
return data
|
||||
|
||||
+40
-49
@@ -1,11 +1,12 @@
|
||||
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||
import {createContext, useCallback, useContext, useMemo} from 'react'
|
||||
|
||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {
|
||||
AgeAssuranceDataProvider,
|
||||
useAgeAssuranceDataContext,
|
||||
AgeAssuranceServerDataProvider,
|
||||
useAgeAssuranceServerDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {
|
||||
@@ -14,37 +15,29 @@ import {
|
||||
} from '#/ageAssurance/state'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceFlags,
|
||||
type AgeAssuranceState,
|
||||
AgeAssuranceStatus,
|
||||
} from '#/ageAssurance/types'
|
||||
import {
|
||||
isUnderAge,
|
||||
maybeRestrictChatSettings,
|
||||
MIN_ACCESS_AGE,
|
||||
computeAgeAssuranceFlags,
|
||||
useAgeAssuranceRegionConfigWithFallback,
|
||||
} from '#/ageAssurance/util'
|
||||
|
||||
export {
|
||||
prefetchConfig as prefetchAgeAssuranceConfig,
|
||||
prefetchAgeAssuranceData,
|
||||
prefetchAgeAssuranceServerData,
|
||||
refetchServerState as refetchAgeAssuranceServerState,
|
||||
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
|
||||
usePatchServerState as usePatchAgeAssuranceServerState,
|
||||
} from '#/ageAssurance/data'
|
||||
export {logger} from '#/ageAssurance/logger'
|
||||
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
|
||||
|
||||
const AgeAssuranceStateContext = createContext<{
|
||||
Access: typeof AgeAssuranceAccess
|
||||
Status: typeof AgeAssuranceStatus
|
||||
state: AgeAssuranceState
|
||||
flags: {
|
||||
adultContentDisabled: boolean
|
||||
chatDisabled: boolean
|
||||
isDeclaredUnderAdultAge: boolean
|
||||
isOverRegionMinAccessAge: boolean
|
||||
isOverAppMinAccessAge: boolean
|
||||
}
|
||||
flags: AgeAssuranceFlags
|
||||
}>({
|
||||
Access: AgeAssuranceAccess,
|
||||
Status: AgeAssuranceStatus,
|
||||
@@ -54,8 +47,10 @@ const AgeAssuranceStateContext = createContext<{
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
flags: {
|
||||
isAgeRestricted: false,
|
||||
adultContentDisabled: false,
|
||||
chatDisabled: false,
|
||||
groupChatDisabled: false,
|
||||
isDeclaredUnderAdultAge: false,
|
||||
isOverRegionMinAccessAge: false,
|
||||
isOverAppMinAccessAge: false,
|
||||
@@ -73,65 +68,61 @@ export function useAgeAssurance() {
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<AgeAssuranceDataProvider>
|
||||
<AgeAssuranceServerDataProvider>
|
||||
<InnerProvider>
|
||||
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
|
||||
</InnerProvider>
|
||||
</AgeAssuranceDataProvider>
|
||||
</AgeAssuranceServerDataProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
const agent = useAgent()
|
||||
const state = useAgeAssuranceState()
|
||||
const {data} = useAgeAssuranceDataContext()
|
||||
const config = useAgeAssuranceRegionConfigWithFallback()
|
||||
const {metadata} = useAgeAssuranceServerDataContext()
|
||||
const regionConfig = useAgeAssuranceRegionConfigWithFallback()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
|
||||
const handleAccessUpdate = useCallback(
|
||||
(s: AgeAssuranceState) => {
|
||||
const isAgeRestricted = s.access !== AgeAssuranceAccess.Full
|
||||
if (isAgeRestricted) {
|
||||
void getAndRegisterPushToken({isAgeRestricted})
|
||||
maybeRestrictChatSettings({agent})
|
||||
const flags = computeAgeAssuranceFlags({
|
||||
state: s,
|
||||
regionConfig,
|
||||
metadata,
|
||||
})
|
||||
if (flags.isAgeRestricted) {
|
||||
void getAndRegisterPushToken({
|
||||
isAgeRestricted: true,
|
||||
})
|
||||
}
|
||||
if (flags.chatDisabled || flags.groupChatDisabled) {
|
||||
void restrictChatSettings({
|
||||
agent,
|
||||
restrictIncoming: flags.chatDisabled,
|
||||
restrictGroupInvites: flags.groupChatDisabled,
|
||||
})
|
||||
}
|
||||
},
|
||||
[agent, getAndRegisterPushToken],
|
||||
[agent, getAndRegisterPushToken, regionConfig, metadata],
|
||||
)
|
||||
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug(`useAgeAssuranceState`, {state})
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<AgeAssuranceStateContext.Provider
|
||||
value={useMemo(() => {
|
||||
const chatDisabled = state.access !== AgeAssuranceAccess.Full
|
||||
const isDeclaredUnderAdultAge = 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 || isDeclaredUnderAdultAge
|
||||
return {
|
||||
const res = {
|
||||
Access: AgeAssuranceAccess,
|
||||
Status: AgeAssuranceStatus,
|
||||
state,
|
||||
flags: {
|
||||
adultContentDisabled,
|
||||
chatDisabled,
|
||||
isDeclaredUnderAdultAge,
|
||||
isOverRegionMinAccessAge,
|
||||
isOverAppMinAccessAge,
|
||||
},
|
||||
flags: computeAgeAssuranceFlags({
|
||||
state,
|
||||
regionConfig,
|
||||
metadata,
|
||||
}),
|
||||
}
|
||||
}, [state, data, config])}>
|
||||
logger.debug(`useAgeAssurance`, res)
|
||||
return res
|
||||
}, [state, metadata, regionConfig])}>
|
||||
{children}
|
||||
</AgeAssuranceStateContext.Provider>
|
||||
)
|
||||
|
||||
+51
-29
@@ -1,24 +1,30 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
import {
|
||||
type AppBskyAgeassuranceDefs,
|
||||
computeAgeAssuranceRegionAccess,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
type AgeAssuranceData,
|
||||
getConfigFromCache,
|
||||
getOtherRequiredDataFromCache,
|
||||
getServerStateFromCache,
|
||||
useAgeAssuranceDataContext,
|
||||
useAgeAssuranceServerDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceMetadata,
|
||||
type AgeAssuranceState,
|
||||
AgeAssuranceStatus,
|
||||
parseAccessFromString,
|
||||
parseStatusFromString,
|
||||
} from '#/ageAssurance/types'
|
||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||
import {
|
||||
computeAgeAssuranceFlags,
|
||||
getAgeAssuranceRegionConfigWithFallback,
|
||||
} from '#/ageAssurance/util'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
import {device} from '#/storage'
|
||||
|
||||
@@ -27,18 +33,18 @@ import {device} from '#/storage'
|
||||
* server state before computing access based on AA config from the server +
|
||||
* geolocation and other data.
|
||||
*/
|
||||
export function computeAgeAssuranceState({
|
||||
function computeAgeAssuranceState({
|
||||
hasSession,
|
||||
config,
|
||||
geolocation,
|
||||
config,
|
||||
state,
|
||||
data,
|
||||
metadata,
|
||||
}: {
|
||||
hasSession: boolean
|
||||
config: AgeAssuranceData['config']
|
||||
geolocation: Geolocation
|
||||
state: AgeAssuranceData['state']
|
||||
data: AgeAssuranceData['data']
|
||||
config?: AppBskyAgeassuranceDefs.Config
|
||||
state?: AppBskyAgeassuranceDefs.State
|
||||
metadata?: AgeAssuranceMetadata
|
||||
}) {
|
||||
/**
|
||||
* This is where we control logged-out moderation prefs. It's all
|
||||
@@ -88,7 +94,10 @@ export function computeAgeAssuranceState({
|
||||
* accounts with an accurate birthdate, our default fallback rules should
|
||||
* ensure correct access.
|
||||
*/
|
||||
const result = computeAgeAssuranceRegionAccess(region, data)
|
||||
const result = computeAgeAssuranceRegionAccess(region, {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: metadata?.declaredAge,
|
||||
})
|
||||
const computed = {
|
||||
lastInitiatedAt: state?.lastInitiatedAt,
|
||||
// prefer server state
|
||||
@@ -100,10 +109,10 @@ export function computeAgeAssuranceState({
|
||||
? parseAccessFromString(result.access)
|
||||
: AgeAssuranceAccess.Full,
|
||||
}
|
||||
logger.debug('debug useAgeAssuranceState', {
|
||||
logger.debug('computeAgeAssuranceState', {
|
||||
region,
|
||||
state,
|
||||
data,
|
||||
metadata,
|
||||
computed,
|
||||
})
|
||||
return computed
|
||||
@@ -113,38 +122,51 @@ export function computeAgeAssuranceState({
|
||||
* This is a last-ditch helper for out-of-band reads of the AA state, such as
|
||||
* during account creation. Don't use it for anything else.
|
||||
*/
|
||||
export function getAndComputeAgeAssuranceState({did}: {did: string}) {
|
||||
export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
const config = getConfigFromCache()
|
||||
const state = getServerStateFromCache({did})
|
||||
const data = getOtherRequiredDataFromCache({did})
|
||||
const requiredData = getOtherRequiredDataFromCache({did})
|
||||
const geolocation = device.get(['mergedGeolocation'])
|
||||
|
||||
if (!geolocation || !config || !state || !data) {
|
||||
if (!geolocation || !config || !state || !requiredData) {
|
||||
return {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Safe,
|
||||
state: {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Safe,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return computeAgeAssuranceState({
|
||||
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
|
||||
const metadata: AgeAssuranceMetadata = {
|
||||
accountCreatedAt: state.metadata?.accountCreatedAt,
|
||||
declaredAge: requiredData?.birthdate
|
||||
? getAge(new Date(requiredData.birthdate))
|
||||
: undefined,
|
||||
birthdate: requiredData?.birthdate,
|
||||
}
|
||||
const computed = computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
config,
|
||||
geolocation,
|
||||
state: state.state,
|
||||
data: {
|
||||
accountCreatedAt: state.metadata?.accountCreatedAt,
|
||||
declaredAge: data?.birthdate
|
||||
? getAge(new Date(data.birthdate))
|
||||
: undefined,
|
||||
birthdate: data?.birthdate,
|
||||
},
|
||||
metadata,
|
||||
})
|
||||
|
||||
return {
|
||||
state: computed,
|
||||
flags: computeAgeAssuranceFlags({
|
||||
state: computed,
|
||||
regionConfig: region,
|
||||
metadata,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
const {hasSession} = useSession()
|
||||
const geolocation = useGeolocation()
|
||||
const {config, state, data} = useAgeAssuranceDataContext()
|
||||
const {config, state, metadata} = useAgeAssuranceServerDataContext()
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -153,9 +175,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
config,
|
||||
geolocation,
|
||||
state,
|
||||
data,
|
||||
metadata,
|
||||
}),
|
||||
[hasSession, geolocation, config, state, data],
|
||||
[hasSession, geolocation, config, state, metadata],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
|
||||
export enum AgeAssuranceAccess {
|
||||
@@ -14,6 +16,12 @@ export enum AgeAssuranceStatus {
|
||||
Blocked = 'blocked',
|
||||
}
|
||||
|
||||
export type AgeAssuranceMetadata = Parameters<
|
||||
typeof computeAgeAssuranceRegionAccess
|
||||
>[1] & {
|
||||
birthdate: string | undefined
|
||||
}
|
||||
|
||||
export type AgeAssuranceState = {
|
||||
lastInitiatedAt?: string
|
||||
status: AgeAssuranceStatus
|
||||
@@ -21,6 +29,16 @@ export type AgeAssuranceState = {
|
||||
error?: 'config' // maybe other specific cases in the future
|
||||
}
|
||||
|
||||
export type AgeAssuranceFlags = {
|
||||
isAgeRestricted: boolean
|
||||
adultContentDisabled: boolean
|
||||
chatDisabled: boolean
|
||||
groupChatDisabled: boolean
|
||||
isDeclaredUnderAdultAge: boolean
|
||||
isOverRegionMinAccessAge: boolean
|
||||
isOverAppMinAccessAge: boolean
|
||||
}
|
||||
|
||||
export function parseStatusFromString(raw: string) {
|
||||
switch (raw) {
|
||||
case 'unknown':
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {useCallback} from 'react'
|
||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
|
||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||
import {type Geolocation} from '#/geolocation'
|
||||
|
||||
export function useComputeAgeAssuranceRegionAccess() {
|
||||
const {config, data} = useAgeAssuranceDataContext()
|
||||
const {config, metadata} = useAgeAssuranceServerDataContext()
|
||||
return useCallback(
|
||||
(geolocation: Geolocation) => {
|
||||
if (!config) {
|
||||
@@ -19,11 +19,14 @@ export function useComputeAgeAssuranceRegionAccess() {
|
||||
config,
|
||||
geolocation,
|
||||
)
|
||||
const result = computeAgeAssuranceRegionAccess(region, data)
|
||||
const result = computeAgeAssuranceRegionAccess(region, {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: metadata?.declaredAge,
|
||||
})
|
||||
return result
|
||||
? parseAccessFromString(result.access)
|
||||
: AgeAssuranceAccess.Full
|
||||
},
|
||||
[config, data],
|
||||
[config, metadata],
|
||||
)
|
||||
}
|
||||
|
||||
+42
-39
@@ -1,41 +1,22 @@
|
||||
import {useMemo} from 'react'
|
||||
import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
type AtpAgent,
|
||||
getAgeAssuranceRegionConfig,
|
||||
type ModerationPrefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
|
||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||
import {
|
||||
getDidFromAgentSession,
|
||||
getOtherRequiredDataFromCache,
|
||||
useAgeAssuranceDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceFlags,
|
||||
type AgeAssuranceMetadata,
|
||||
type AgeAssuranceState,
|
||||
} from '#/ageAssurance/types'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
|
||||
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
|
||||
* app defaults if no region config is found.
|
||||
@@ -62,7 +43,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
|
||||
*/
|
||||
export function useAgeAssuranceRegionConfig() {
|
||||
const geolocation = useGeolocation()
|
||||
const {config} = useAgeAssuranceDataContext()
|
||||
const {config} = useAgeAssuranceServerDataContext()
|
||||
return useMemo(() => {
|
||||
if (!config) return
|
||||
// use generic helper, we want to potentially return undefined
|
||||
@@ -116,15 +97,37 @@ export const makeAgeRestrictedModerationPrefs = (
|
||||
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
||||
})
|
||||
|
||||
/**
|
||||
* Checks our cache of the actor's chat declaration record, and if it's not
|
||||
* already restricted, restricts it.
|
||||
*/
|
||||
export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (!did) return
|
||||
const data = getOtherRequiredDataFromCache({did})
|
||||
// ...update the chat setting record if allowIncoming is not already 'none'.
|
||||
if (data?.actorDeclaration?.allowIncoming === 'none') return
|
||||
restrictChatSettings({agent, did})
|
||||
export function computeAgeAssuranceFlags({
|
||||
state,
|
||||
regionConfig,
|
||||
metadata,
|
||||
}: {
|
||||
state: AgeAssuranceState
|
||||
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
|
||||
metadata?: AgeAssuranceMetadata
|
||||
}): AgeAssuranceFlags {
|
||||
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
|
||||
const chatDisabled = isAgeRestricted
|
||||
const isDeclaredUnderAdultAge = metadata?.declaredAge
|
||||
? metadata.declaredAge < 18
|
||||
: true
|
||||
const groupChatDisabled = chatDisabled || isDeclaredUnderAdultAge
|
||||
const isOverRegionMinAccessAge = metadata?.declaredAge
|
||||
? metadata.declaredAge >= regionConfig.minAccessAge
|
||||
: false
|
||||
const isOverAppMinAccessAge = metadata?.declaredAge
|
||||
? metadata.declaredAge >= MIN_ACCESS_AGE
|
||||
: false
|
||||
const adultContentDisabled =
|
||||
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
|
||||
|
||||
return {
|
||||
isAgeRestricted,
|
||||
adultContentDisabled,
|
||||
chatDisabled,
|
||||
groupChatDisabled,
|
||||
isDeclaredUnderAdultAge,
|
||||
isOverRegionMinAccessAge,
|
||||
isOverAppMinAccessAge,
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -9,7 +9,12 @@ import {
|
||||
setFontScale as persistFontScale,
|
||||
} from '#/alf/fonts'
|
||||
import {themes} from '#/alf/themes'
|
||||
import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration'
|
||||
import {
|
||||
contrastRatio,
|
||||
darken,
|
||||
lighten,
|
||||
rgbToHex,
|
||||
} from '#/alf/util/colorGeneration'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
|
||||
@@ -26,6 +31,7 @@ export const utils = {
|
||||
rgbToHex,
|
||||
lighten,
|
||||
darken,
|
||||
contrastRatio,
|
||||
}
|
||||
|
||||
export type Alf = {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration'
|
||||
import {
|
||||
contrastRatio,
|
||||
darken,
|
||||
hexToRgb,
|
||||
lighten,
|
||||
rgbToHex,
|
||||
} from './colorGeneration'
|
||||
|
||||
describe('hexToRgb', () => {
|
||||
it('parses 6-digit hex', () => {
|
||||
@@ -92,3 +98,33 @@ describe('lighten / darken', () => {
|
||||
expect(darken('#zzz', 10)).toBe('#zzz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contrastRatio', () => {
|
||||
it('returns 21 for black on white', () => {
|
||||
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5)
|
||||
})
|
||||
|
||||
it('returns 1 for identical colors', () => {
|
||||
expect(contrastRatio('#abcdef', '#abcdef')).toBeCloseTo(1, 5)
|
||||
})
|
||||
|
||||
it('is symmetric regardless of argument order', () => {
|
||||
expect(contrastRatio('#123456', '#fedcba')).toBeCloseTo(
|
||||
contrastRatio('#fedcba', '#123456')!,
|
||||
5,
|
||||
)
|
||||
})
|
||||
|
||||
it('clears AAA large text (4.5:1) for a high-contrast pairing', () => {
|
||||
expect(contrastRatio('#1d3a5f', '#ffffff')!).toBeGreaterThanOrEqual(4.5)
|
||||
})
|
||||
|
||||
it('fails AAA large text (4.5:1) for a low-contrast pairing', () => {
|
||||
expect(contrastRatio('#777777', '#888888')!).toBeLessThan(4.5)
|
||||
})
|
||||
|
||||
it('returns null for invalid hex input', () => {
|
||||
expect(contrastRatio('not-a-color', '#ffffff')).toBeNull()
|
||||
expect(contrastRatio('#ffffff', '#zzz')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,6 +72,48 @@ export function rgbToHex(r: number, g: number, b: number): string {
|
||||
.slice(1)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the WCAG contrast ratio between two colors, ranging from 1 (no
|
||||
* contrast) to 21 (maximum contrast, i.e. black on white). Returns null if
|
||||
* either argument is not a valid hex color.
|
||||
*
|
||||
* @see https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
|
||||
*/
|
||||
export function contrastRatio(hexA: string, hexB: string): number | null {
|
||||
const rgbA = hexToRgb(hexA)
|
||||
const rgbB = hexToRgb(hexB)
|
||||
if (!rgbA || !rgbB) return null
|
||||
const luminanceA = relativeLuminance(rgbA)
|
||||
const luminanceB = relativeLuminance(rgbB)
|
||||
const lighter = Math.max(luminanceA, luminanceB)
|
||||
const darker = Math.min(luminanceA, luminanceB)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the WCAG relative luminance of an RGB color, ranging from 0 (black)
|
||||
* to 1 (white).
|
||||
*
|
||||
* @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
||||
*/
|
||||
function relativeLuminance({
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
}: {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}): number {
|
||||
const toLinear = (channel: number) => {
|
||||
const normalized = channel / 255
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
|
||||
}
|
||||
|
||||
function rgbToHsl(
|
||||
r: number,
|
||||
g: number,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
|
||||
/**
|
||||
* Same as `useWindowDimensions().fontScale`, but avoids rerendering
|
||||
* whenever the screen size changes
|
||||
*/
|
||||
export function useNativeFontScale() {
|
||||
const [fontScale, setFontScale] = useState(Dimensions.get('window').fontScale)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = Dimensions.addEventListener('change', evt => {
|
||||
setFontScale(evt.window.fontScale)
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
|
||||
return fontScale
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import {useEffect, useRef} from 'react'
|
||||
|
||||
import {getCurrentState, onAppStateChange} from '#/lib/appState'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
import {IS_DEV, IS_TESTFLIGHT} from '#/env'
|
||||
|
||||
/**
|
||||
* Tracks passive analytics like app foreground/background time.
|
||||
@@ -27,19 +25,19 @@ export function PassiveAnalytics() {
|
||||
})
|
||||
}
|
||||
|
||||
if (IS_DEV || IS_TESTFLIGHT) {
|
||||
const feats = Object.values(Features).reduce(
|
||||
(acc, feat) => {
|
||||
acc[feat] = features.evalFeature(feat)
|
||||
return acc
|
||||
},
|
||||
{} as Record<Features, any>,
|
||||
)
|
||||
ax.logger.info('FEATURES', {
|
||||
features: feats,
|
||||
definitions: features.getFeatures(),
|
||||
})
|
||||
}
|
||||
// if (IS_DEV || IS_TESTFLIGHT) {
|
||||
// const feats = Object.values(Features).reduce(
|
||||
// (acc, feat) => {
|
||||
// acc[feat] = features.evalFeature(feat)
|
||||
// return acc
|
||||
// },
|
||||
// {} as Record<Features, any>,
|
||||
// )
|
||||
// ax.logger.info('FEATURES', {
|
||||
// features: feats,
|
||||
// definitions: features.getFeatures(),
|
||||
// })
|
||||
// }
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [ax])
|
||||
|
||||
@@ -9,8 +9,6 @@ export enum Features {
|
||||
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
ImageUploadsHighResolution = 'image_uploads:high_resolution',
|
||||
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
|
||||
GroupChatsEnable = 'group_chats:enable',
|
||||
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
@@ -18,5 +16,6 @@ export enum Features {
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
||||
LargeVideoUploads = 'large_video_uploads:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -67,10 +67,6 @@ export class MetricsClient<M extends Record<string, any>> {
|
||||
}
|
||||
|
||||
private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
|
||||
logger.debug(`sendBatch: ${events.length}`, {
|
||||
isRetry,
|
||||
})
|
||||
|
||||
try {
|
||||
const body = JSON.stringify({events})
|
||||
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
|
||||
|
||||
@@ -1175,18 +1175,37 @@ export type Events = {
|
||||
'profile:associated:germ:self-disconnect': {}
|
||||
'profile:associated:germ:self-reconnect': {}
|
||||
|
||||
// Gallery carousel events
|
||||
'post:gallery:swipe': {
|
||||
// Post photo embed events
|
||||
'post:photoEmbed:impression': {
|
||||
layout: 'single' | 'grid' | 'carousel'
|
||||
totalImages: number
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
'post:photoEmbed:open': {
|
||||
layout: 'single' | 'grid' | 'carousel'
|
||||
fromImage: number
|
||||
totalImages: number
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
'post:photoEmbed:carouselSwipe': {
|
||||
fromImage: number
|
||||
toImage: number
|
||||
totalImages: number
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
'post:gallery:openLightbox': {
|
||||
'post:photoEmbed:lightboxSwipe': {
|
||||
layout: 'single' | 'grid' | 'carousel'
|
||||
fromImage: number
|
||||
totalImages: number
|
||||
}
|
||||
'post:gallery:impression': {
|
||||
toImage: number
|
||||
totalImages: number
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useEffect, useMemo} from 'react'
|
||||
import {useEffect} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -8,12 +8,10 @@ import Animated, {
|
||||
withDelay,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
type ModerationUI,
|
||||
} from '@atproto/api'
|
||||
import {moderateProfile} from '@atproto/api'
|
||||
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -28,30 +26,28 @@ type Layout = {
|
||||
border?: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
animate?: boolean
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
size?: number
|
||||
moderationOpts?: ModerationOpts
|
||||
}
|
||||
|
||||
export function AvatarBubbles({
|
||||
animate = false,
|
||||
profiles: allProfiles,
|
||||
self = false,
|
||||
size = 120,
|
||||
moderationOpts,
|
||||
}: Props) {
|
||||
}: {
|
||||
animate?: boolean
|
||||
profiles: (bsky.profile.AnyProfileView | undefined)[]
|
||||
/**
|
||||
* By default, when there are more than 2 profiles, the current user is
|
||||
* filtered out (so you don't see yourself among your own group's members).
|
||||
* Set this to `true` for cases where every passed profile should appear,
|
||||
* e.g. an invite preview where the owner is meaningful regardless of viewer.
|
||||
*/
|
||||
self?: boolean
|
||||
size?: number
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const profiles =
|
||||
allProfiles.length > 2
|
||||
? allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||
!self && allProfiles.length > 2
|
||||
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
|
||||
: allProfiles
|
||||
const moderations = useMemo(() => {
|
||||
if (!moderationOpts) return []
|
||||
return profiles.map(p => {
|
||||
return moderateProfile(p, moderationOpts)
|
||||
})
|
||||
}, [profiles, moderationOpts])
|
||||
|
||||
const scale = size / 120
|
||||
const marginOffset = size < 120 ? -2 : 0
|
||||
@@ -104,7 +100,6 @@ export function AvatarBubbles({
|
||||
y={layout.y}
|
||||
zIndex={layout.zIndex}
|
||||
includeProfileBorder={layout.border}
|
||||
moderation={moderations[i]?.ui('avatar')}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -113,14 +108,13 @@ export function AvatarBubbles({
|
||||
}
|
||||
|
||||
function AvatarBubble({
|
||||
profile,
|
||||
profile: profileUnshadowed,
|
||||
scale,
|
||||
size,
|
||||
x,
|
||||
y,
|
||||
zIndex,
|
||||
includeProfileBorder,
|
||||
moderation,
|
||||
}: {
|
||||
profile?: bsky.profile.AnyProfileView
|
||||
scale: SharedValue<number>
|
||||
@@ -129,14 +123,15 @@ function AvatarBubble({
|
||||
y: number
|
||||
zIndex?: number
|
||||
includeProfileBorder?: boolean
|
||||
moderation?: ModerationUI
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
|
||||
}))
|
||||
|
||||
const profile = useMaybeProfileShadow(profileUnshadowed)
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
@@ -150,14 +145,14 @@ function AvatarBubble({
|
||||
zIndex != null && {zIndex},
|
||||
animatedStyle,
|
||||
]}>
|
||||
{profile ? (
|
||||
{profile && moderationOpts ? (
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={size}
|
||||
type="user"
|
||||
hideLiveBadge
|
||||
noBorder
|
||||
moderation={moderation}
|
||||
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
|
||||
/>
|
||||
) : (
|
||||
<AvatarPlaceholder size={size} />
|
||||
|
||||
@@ -45,7 +45,7 @@ export type ButtonColor =
|
||||
| 'negative'
|
||||
| 'primary_subtle'
|
||||
| 'negative_subtle'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'large'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
@@ -136,7 +136,7 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant,
|
||||
variant: variantProp,
|
||||
color,
|
||||
size,
|
||||
shape = 'default',
|
||||
@@ -160,7 +160,8 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
* If a `color` is set, then we want to use the existing codepaths for
|
||||
* "solid" buttons. This is to maintain backwards compatibility.
|
||||
*/
|
||||
if (!variant && color) {
|
||||
let variant: VariantProps['variant'] = variantProp
|
||||
if (!variantProp && color) {
|
||||
variant = 'solid'
|
||||
}
|
||||
|
||||
@@ -458,6 +459,12 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
paddingHorizontal: 24,
|
||||
gap: 6,
|
||||
})
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 28,
|
||||
gap: 5,
|
||||
})
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 8,
|
||||
@@ -479,6 +486,13 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
borderRadius: 10,
|
||||
gap: 3,
|
||||
})
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push({
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
gap: 3,
|
||||
})
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({
|
||||
paddingVertical: 8,
|
||||
@@ -505,6 +519,12 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
} else {
|
||||
baseStyles.push({height: 44, width: 44})
|
||||
}
|
||||
} else if (size === 'medium') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
} else {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
}
|
||||
} else if (size === 'small') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
@@ -757,11 +777,13 @@ export function useSharedButtonTextStyles() {
|
||||
}
|
||||
|
||||
if (size === 'large') {
|
||||
baseStyles.push(a.text_md, a.leading_snug, a.font_medium)
|
||||
baseStyles.push(a.text_md, a.font_medium)
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push(a.text_sm, a.font_medium)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.text_sm, a.leading_snug, a.font_medium)
|
||||
baseStyles.push(a.text_sm, a.font_medium)
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push(a.text_xs, a.leading_snug, a.font_semi_bold)
|
||||
baseStyles.push(a.text_xs, a.font_semi_bold)
|
||||
}
|
||||
|
||||
return flatten(baseStyles)
|
||||
@@ -799,6 +821,7 @@ export function ButtonIcon({
|
||||
size ??
|
||||
(({
|
||||
large: 'md',
|
||||
medium: 'sm',
|
||||
small: 'sm',
|
||||
tiny: 'xs',
|
||||
}[buttonSize || 'small'] || 'sm') as Exclude<
|
||||
@@ -828,6 +851,7 @@ export function ButtonIcon({
|
||||
*/
|
||||
const iconContainerSize = {
|
||||
large: 20,
|
||||
medium: 17,
|
||||
small: 17,
|
||||
tiny: 15,
|
||||
}[buttonSize || 'small']
|
||||
@@ -841,6 +865,7 @@ export function ButtonIcon({
|
||||
if (buttonShape === 'default') {
|
||||
iconNegativeMargin = {
|
||||
large: -2,
|
||||
medium: -2,
|
||||
small: -2,
|
||||
tiny: -1,
|
||||
}[buttonSize || 'small']
|
||||
|
||||
@@ -499,6 +499,7 @@ function TriggerClone({
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={_(msg`The subject of the context menu`)}
|
||||
accessibilityIgnoresInvertColors={false}
|
||||
cachePolicy="none"
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useGoBack} from '#/lib/hooks/useGoBack'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Error({
|
||||
@@ -15,22 +13,20 @@ export function Error({
|
||||
onRetry,
|
||||
onGoBack,
|
||||
hideBackButton,
|
||||
sideBorders = true,
|
||||
}: {
|
||||
title?: string
|
||||
message?: string
|
||||
onRetry?: () => unknown
|
||||
onGoBack?: () => unknown
|
||||
hideBackButton?: boolean
|
||||
sideBorders?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const goBack = useGoBack(onGoBack)
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
<Layout.Center
|
||||
style={[
|
||||
a.h_full_vh,
|
||||
a.align_center,
|
||||
@@ -38,8 +34,7 @@ export function Error({
|
||||
!gtMobile && a.justify_between,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders={sideBorders}>
|
||||
]}>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
|
||||
<Text
|
||||
@@ -58,7 +53,7 @@ export function Error({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label={_(msg`Press to retry`)}
|
||||
label={l`Press to retry`}
|
||||
onPress={onRetry}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
@@ -70,7 +65,7 @@ export function Error({
|
||||
<Button
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label={_(msg`Return to previous page`)}
|
||||
label={l`Return to previous page`}
|
||||
onPress={goBack}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
@@ -79,6 +74,6 @@ export function Error({
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</CenteredView>
|
||||
</Layout.Center>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {FocusGuards, FocusScope} from 'radix-ui/internal'
|
||||
@@ -226,13 +226,21 @@ function LightboxGallery({
|
||||
)}
|
||||
</View>
|
||||
{img.alt ? (
|
||||
<View
|
||||
<ScrollView
|
||||
// Cap the overlay height so long alt text scrolls within the overlay
|
||||
// instead of growing past the top of the screen and pushing the image
|
||||
// out of view. Only scrollable once expanded.
|
||||
style={[
|
||||
a.px_4xl,
|
||||
a.py_2xl,
|
||||
{backgroundColor: 'rgba(0, 0, 0, 0.45)'},
|
||||
styles.altScroll,
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
// @ts-expect-error web only
|
||||
backdropFilter: 'blur(16px)',
|
||||
},
|
||||
delayedFadeInAnim,
|
||||
]}>
|
||||
]}
|
||||
scrollEnabled={isAltExpanded}
|
||||
contentContainerStyle={[a.px_4xl, a.py_2xl]}>
|
||||
<Pressable
|
||||
accessibilityLabel={l`Expand alt text`}
|
||||
accessibilityHint={l`If alt text is long, toggles alt text expanded state`}
|
||||
@@ -246,7 +254,7 @@ function LightboxGallery({
|
||||
{img.alt}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{imgs.length > 1 && (
|
||||
<div aria-live="polite" aria-atomic="true" style={a.sr_only}>
|
||||
@@ -445,6 +453,14 @@ const styles = StyleSheet.create({
|
||||
padding: 16,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
altScroll: {
|
||||
// Size to content like the View it replaced, rather than filling the
|
||||
// column via ScrollView's default flexGrow.
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
// @ts-ignore web-only -sfn
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
menuBtn: {
|
||||
top: 20,
|
||||
left: 20,
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import {useRef} from 'react'
|
||||
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
|
||||
import {
|
||||
LayoutAnimation,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
useSafeAreaFrame,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {BlurView} from 'expo-blur'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, platform, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Props = {
|
||||
@@ -22,10 +20,16 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const insets = useSafeAreaInsets()
|
||||
const {height: screenHeight} = useSafeAreaFrame()
|
||||
const isMomentumScrolling = useRef(false)
|
||||
|
||||
if (!altText) return null
|
||||
|
||||
// Cap the overlay height so long alt text - or text enlarged by the OS via
|
||||
// Dynamic Type / font scaling - scrolls within the overlay instead of growing
|
||||
// past the top of the screen. Leaves the upper half clear for the header.
|
||||
const maxHeight = screenHeight / 2
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -37,36 +41,66 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
{paddingBottom: insets.bottom + 8},
|
||||
]}>
|
||||
<View style={[a.mx_md, styles.altWrap]}>
|
||||
<ScrollView
|
||||
scrollEnabled={isAltExpanded}
|
||||
onMomentumScrollBegin={() => {
|
||||
isMomentumScrolling.current = true
|
||||
}}
|
||||
onMomentumScrollEnd={() => {
|
||||
isMomentumScrolling.current = false
|
||||
}}
|
||||
contentContainerStyle={[a.px_md, a.py_sm]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Expand alt text`}
|
||||
accessibilityHint=""
|
||||
onPress={() => {
|
||||
if (isMomentumScrolling.current) return
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 450,
|
||||
update: {type: 'spring', springDamping: 1},
|
||||
})
|
||||
onToggleAltExpanded()
|
||||
}}>
|
||||
<Text
|
||||
emoji
|
||||
selectable
|
||||
style={[a.text_sm, {color: t.palette.white}]}
|
||||
numberOfLines={isAltExpanded ? undefined : 3}>
|
||||
{altText}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
<BlurView
|
||||
intensity={16}
|
||||
tint="dark"
|
||||
style={[
|
||||
// Tint kept over the blur so dense, text-heavy images stay
|
||||
// readable. On Android the blur falls back to a flat overlay, so
|
||||
// bump the opacity to keep the contrast the real blur provides
|
||||
// elsewhere.
|
||||
platform({
|
||||
ios: {backgroundColor: 'rgba(0, 0, 0, 0.5)'},
|
||||
android: {backgroundColor: 'rgba(0, 0, 0, 0.7)'},
|
||||
}),
|
||||
]}>
|
||||
<ScrollView
|
||||
style={{maxHeight}}
|
||||
scrollEnabled={isAltExpanded}
|
||||
onMomentumScrollBegin={() => {
|
||||
isMomentumScrolling.current = true
|
||||
}}
|
||||
onMomentumScrollEnd={() => {
|
||||
isMomentumScrolling.current = false
|
||||
}}
|
||||
contentContainerStyle={[a.px_md, a.py_sm]}>
|
||||
<View
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Expand alt text`}
|
||||
accessibilityHint="">
|
||||
{/*
|
||||
* The press handlers must live on the Text itself, not on a
|
||||
* wrapping Pressable. Text selection is driven by the platform's
|
||||
* native text view long-press (RN Text on Android, UITextView on
|
||||
* iOS). A parent touchable consumes that long-press before the
|
||||
* selectable Text can begin a selection - on Android this prevents
|
||||
* selection entirely. Keeping onPress/onLongPress on the Text lets
|
||||
* the same native node own both the tap-to-expand and the
|
||||
* long-press-to-select. The empty onLongPress is intentional: it
|
||||
* reserves the long-press for the OS selection gesture instead of
|
||||
* firing the expand toggle. RN exposes no API to arbitrate tap vs
|
||||
* native selection on a single node, so this is the supported
|
||||
* workaround, and it behaves consistently on both platforms.
|
||||
*/}
|
||||
<Text
|
||||
emoji
|
||||
selectable
|
||||
style={[a.text_sm, {color: t.palette.white}]}
|
||||
numberOfLines={isAltExpanded ? undefined : 3}
|
||||
onPress={() => {
|
||||
if (isMomentumScrolling.current) return
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 450,
|
||||
update: {type: 'spring', springDamping: 1},
|
||||
})
|
||||
onToggleAltExpanded()
|
||||
}}
|
||||
onLongPress={() => {}}>
|
||||
{altText}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</BlurView>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -74,7 +108,6 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
altWrap: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {android, atoms as a, ios} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||
import {type Props as IconProps} from '#/components/icons/common'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
|
||||
@@ -25,7 +25,6 @@ type Props = {
|
||||
|
||||
type Anchor = {x: number; y: number; width: number; height: number}
|
||||
|
||||
const MENU_WIDTH = 160
|
||||
const GAP = 6
|
||||
const CARD_BG = '#000000'
|
||||
const CARD_BORDER = '#232e3e'
|
||||
@@ -124,9 +123,8 @@ function MenuCard({
|
||||
<Animated.View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.self_start,
|
||||
styles.card,
|
||||
android({alignSelf: 'flex-start'}),
|
||||
ios({width: MENU_WIDTH}),
|
||||
{
|
||||
top: anchor.y + anchor.height + GAP,
|
||||
left: anchor.x,
|
||||
@@ -186,7 +184,6 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
itemText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
lineHeight: 19.5,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {BlurView} from 'expo-blur'
|
||||
|
||||
type Props = {
|
||||
count: number
|
||||
@@ -14,26 +13,38 @@ const GAP = 5
|
||||
export function PagerDots({count, activeIndex}: Props) {
|
||||
if (count <= 1) return null
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
|
||||
{Array.from({length: count}).map((_, i) => {
|
||||
const isActive = i === activeIndex
|
||||
return (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
isActive ? styles.active : styles.inactive,
|
||||
isActive ? styles.activeDot : styles.inactiveDot,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<View style={styles.root}>
|
||||
<BlurView intensity={20} tint="dark" style={styles.inner}>
|
||||
{Array.from({length: count}).map((_, i) => {
|
||||
const isActive = i === activeIndex
|
||||
return (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
isActive ? styles.active : styles.inactive,
|
||||
isActive ? styles.activeDot : styles.inactiveDot,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</BlurView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
root: {
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
inner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: GAP,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
activeDot: {
|
||||
width: ACTIVE,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
|
||||
type Props = {
|
||||
count: number
|
||||
activeIndex: number
|
||||
}
|
||||
|
||||
const ACTIVE = 6
|
||||
const INACTIVE = 4
|
||||
const GAP = 5
|
||||
|
||||
export function PagerDots({count, activeIndex}: Props) {
|
||||
if (count <= 1) return null
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{Array.from({length: count}).map((_, i) => {
|
||||
const isActive = i === activeIndex
|
||||
return (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
isActive ? styles.active : styles.inactive,
|
||||
isActive ? styles.activeDot : styles.inactiveDot,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: GAP,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
// @ts-expect-error web-only
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
},
|
||||
activeDot: {
|
||||
width: ACTIVE,
|
||||
height: ACTIVE,
|
||||
borderRadius: ACTIVE / 2,
|
||||
},
|
||||
inactiveDot: {
|
||||
width: INACTIVE,
|
||||
height: INACTIVE,
|
||||
borderRadius: INACTIVE / 2,
|
||||
},
|
||||
active: {
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
inactive: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
})
|
||||
@@ -246,6 +246,7 @@ const ImageItem = ({
|
||||
}
|
||||
}
|
||||
cachePolicy="memory"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
@@ -32,12 +32,14 @@ import Animated, {
|
||||
withSpring,
|
||||
type WithSpringConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import * as ScreenOrientation from 'expo-screen-orientation'
|
||||
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
import {useTheme} from '#/alf'
|
||||
import {setSystemUITheme} from '#/alf/util/systemUI'
|
||||
import {type Lightbox} from '#/components/Lightbox/state'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_IOS} from '#/env'
|
||||
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
|
||||
import {Footer} from '../chrome/Footer'
|
||||
@@ -136,6 +138,9 @@ export default function ImageViewRoot({
|
||||
'worklet'
|
||||
thumbRects.set({})
|
||||
})()
|
||||
requestIdleCallback(() => {
|
||||
void Image.clearMemoryCache()
|
||||
})
|
||||
}, [thumbRects])
|
||||
|
||||
useAnimatedReaction(
|
||||
@@ -224,7 +229,8 @@ function ImageView({
|
||||
openProgress: SharedValue<number>
|
||||
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
||||
}) {
|
||||
const {images, index: initialImageIndex} = lightbox
|
||||
const {images, index: initialImageIndex, metricsContext} = lightbox
|
||||
const ax = useAnalytics()
|
||||
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -373,7 +379,21 @@ function ImageView({
|
||||
scrollEnabled={!isScaled}
|
||||
initialPage={initialImageIndex}
|
||||
onPageSelected={e => {
|
||||
setImageIndex(e.nativeEvent.position)
|
||||
const next = e.nativeEvent.position
|
||||
setImageIndex(prev => {
|
||||
if (metricsContext && prev !== next) {
|
||||
ax.metric('post:photoEmbed:lightboxSwipe', {
|
||||
layout: metricsContext.layout,
|
||||
fromImage: prev + 1,
|
||||
toImage: next + 1,
|
||||
totalImages: images.length,
|
||||
postUri: metricsContext.postUri,
|
||||
postAuthorDid: metricsContext.postAuthorDid,
|
||||
feedDescriptor: metricsContext.feedDescriptor,
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
setIsScaled(false)
|
||||
}}
|
||||
onPageScrollStateChanged={e => {
|
||||
|
||||
@@ -11,10 +11,20 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {useHotkeysContext} from '#/lib/hotkeys'
|
||||
import {type ImageSource} from '#/components/Lightbox/types'
|
||||
|
||||
export type LightboxMetricsContext = {
|
||||
layout: 'single' | 'grid' | 'carousel'
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
|
||||
export type Lightbox = {
|
||||
id: string
|
||||
images: ImageSource[]
|
||||
index: number
|
||||
// Set for post photo embeds so the lightbox can emit post:photoEmbed:lightboxSwipe.
|
||||
// Left unset for non-post contexts (e.g. profile avatar/banner lightbox).
|
||||
metricsContext?: LightboxMetricsContext
|
||||
}
|
||||
|
||||
const LightboxContext = createContext<{
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@react-navigation/native'
|
||||
|
||||
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
||||
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
|
||||
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
|
||||
@@ -19,6 +20,7 @@ import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
createProxiedUrl,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyDownloadUrl,
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
@@ -130,6 +132,7 @@ export function useLink({
|
||||
const {closeModal} = useModalControls()
|
||||
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
||||
const openLink = useOpenLink()
|
||||
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||
|
||||
const onPress = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
@@ -148,6 +151,12 @@ export function useLink({
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const chatInviteCode = getChatInviteCodeFromUrl(href)
|
||||
if (chatInviteCode) {
|
||||
groupChatJoinIntent(chatInviteCode, href)
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresWarning) {
|
||||
linkWarningDialogControl.open({
|
||||
displayText,
|
||||
@@ -228,6 +237,7 @@ export function useLink({
|
||||
overridePresentation,
|
||||
shouldProxy,
|
||||
linkWarningDialogControl,
|
||||
groupChatJoinIntent,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -185,7 +185,6 @@ let ListMaybePlaceholder = ({
|
||||
message={errorMessage ?? _(msg`Something went wrong!`)}
|
||||
onRetry={onRetry}
|
||||
onGoBack={onGoBack}
|
||||
sideBorders={sideBorders}
|
||||
hideBackButton={hideBackButton}
|
||||
/>
|
||||
)
|
||||
@@ -226,7 +225,6 @@ let ListMaybePlaceholder = ({
|
||||
onRetry={onRetry}
|
||||
onGoBack={onGoBack}
|
||||
hideBackButton={hideBackButton}
|
||||
sideBorders={sideBorders}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {
|
||||
AppBskyEmbedGallery,
|
||||
type AppBskyEmbedImages,
|
||||
type AppBskyFeedDefs,
|
||||
} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {shareImageModal} from '#/lib/media/manip'
|
||||
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
|
||||
import {isGifEmbed} from '#/lib/strings/embed-player'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as PeekMenu from '#/components/PeekMenu'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import * as bsky from '#/types/bsky'
|
||||
@@ -16,9 +25,11 @@ import * as bsky from '#/types/bsky'
|
||||
export function Embed({
|
||||
embed,
|
||||
style,
|
||||
peekable = false,
|
||||
}: {
|
||||
embed: AppBskyFeedDefs.PostView['embed']
|
||||
style?: StyleProp<ViewStyle>
|
||||
peekable?: boolean
|
||||
}) {
|
||||
const e = bsky.post.parseEmbed(embed)
|
||||
|
||||
@@ -27,15 +38,47 @@ export function Embed({
|
||||
if (e.type === 'images') {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
{e.view.images.map(image => (
|
||||
<ImageItem
|
||||
key={image.thumb}
|
||||
thumbnail={image.thumb}
|
||||
alt={image.alt}
|
||||
/>
|
||||
))}
|
||||
{e.view.images.map(image =>
|
||||
peekable ? (
|
||||
<PeekableImageItem key={image.thumb} image={image} />
|
||||
) : (
|
||||
<ImageItem
|
||||
key={image.thumb}
|
||||
thumbnail={image.thumb}
|
||||
alt={image.alt}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Outer>
|
||||
)
|
||||
} else if (e.type === 'gallery') {
|
||||
// Notification/DM preview is a narrow inline strip; cap at 4 tiles so
|
||||
// a 10-image gallery doesn't blow out the row width. Single pass instead
|
||||
// of filter().slice().map() so we stop at 4 viewable items rather than
|
||||
// walking every item in a 10-image gallery.
|
||||
const tiles: React.ReactNode[] = []
|
||||
for (const item of e.view.items) {
|
||||
if (tiles.length >= 4) break
|
||||
if (!AppBskyEmbedGallery.isViewImage(item)) continue
|
||||
if (peekable) {
|
||||
const image: AppBskyEmbedImages.ViewImage = {
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
}
|
||||
tiles.push(<PeekableImageItem key={item.thumbnail} image={image} />)
|
||||
} else {
|
||||
tiles.push(
|
||||
<ImageItem
|
||||
key={item.thumbnail}
|
||||
thumbnail={item.thumbnail}
|
||||
alt={item.alt}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}
|
||||
return <Outer style={style}>{tiles}</Outer>
|
||||
} else if (e.type === 'link') {
|
||||
if (!e.view.external.thumb) return null
|
||||
if (!isGifEmbed(e.view.external.uri)) return null
|
||||
@@ -84,10 +127,12 @@ export function ImageItem({
|
||||
thumbnail,
|
||||
alt,
|
||||
children,
|
||||
maxWidth = 100,
|
||||
}: {
|
||||
thumbnail?: string
|
||||
alt?: string
|
||||
children?: React.ReactNode
|
||||
maxWidth?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -98,7 +143,7 @@ export function ImageItem({
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
a.aspect_square,
|
||||
{maxWidth: 100},
|
||||
{maxWidth},
|
||||
a.rounded_xs,
|
||||
]}
|
||||
accessibilityLabel={alt}
|
||||
@@ -109,7 +154,7 @@ export function ImageItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
|
||||
<View style={[a.relative, a.aspect_square, {maxWidth}]}>
|
||||
<Image
|
||||
key={thumbnail}
|
||||
source={{uri: thumbnail}}
|
||||
@@ -118,6 +163,7 @@ export function ImageItem({
|
||||
contentFit="cover"
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<MediaInsetBorder style={[a.rounded_xs]} />
|
||||
{children}
|
||||
@@ -156,6 +202,45 @@ export function VideoItem({
|
||||
)
|
||||
}
|
||||
|
||||
function PeekableImageItem({image}: {image: AppBskyEmbedImages.ViewImage}) {
|
||||
const {t: l} = useLingui()
|
||||
const saveImage = useSaveImageToMediaLibrary()
|
||||
|
||||
const aspect =
|
||||
image.aspectRatio && image.aspectRatio.height > 0
|
||||
? image.aspectRatio.width / image.aspectRatio.height
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<PeekMenu.Root style={[a.flex_1, {maxWidth: 100}]}>
|
||||
<PeekMenu.Trigger
|
||||
preview={{
|
||||
type: 'image',
|
||||
uri: image.fullsize,
|
||||
thumbUri: image.thumb,
|
||||
aspectRatio: aspect && aspect > 0 ? aspect : 1,
|
||||
}}
|
||||
borderRadius={tokens.borderRadius.xs}>
|
||||
<ImageItem thumbnail={image.thumb} alt={image.alt} />
|
||||
</PeekMenu.Trigger>
|
||||
<PeekMenu.Menu>
|
||||
<PeekMenu.MenuItem
|
||||
id="save"
|
||||
onSelect={() => void saveImage(image.fullsize)}>
|
||||
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
|
||||
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
|
||||
</PeekMenu.MenuItem>
|
||||
<PeekMenu.MenuItem
|
||||
id="share"
|
||||
onSelect={() => void shareImageModal({uri: image.fullsize})}>
|
||||
<PeekMenu.MenuItemIcon icon={ShareIcon} />
|
||||
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
|
||||
</PeekMenu.MenuItem>
|
||||
</PeekMenu.Menu>
|
||||
</PeekMenu.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
altContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from '@bsky.app/peek-menu'
|
||||
@@ -172,7 +172,7 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
|
||||
return (
|
||||
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
|
||||
<Text style={[a.text_xs, a.leading_tight]}>
|
||||
<Trans>Follows You</Trans>
|
||||
<Trans>Follows you</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {type StyleProp, type ViewStyle} from 'react-native'
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
|
||||
import {JoinRequestEmbedBody} from '#/components/Post/Embed/JoinRequestEmbed'
|
||||
|
||||
/**
|
||||
* Renders a chat invite link found in an `app.bsky.embed.external` embed (e.g.
|
||||
* a `bsky.app/chat/<code>` link posted to the feed) as a join request card,
|
||||
* falling back to a plain external embed if the invite can't be resolved.
|
||||
*/
|
||||
export function ChatInviteEmbed({
|
||||
code,
|
||||
link,
|
||||
onOpen,
|
||||
style,
|
||||
}: {
|
||||
code: string
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
onOpen?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
return (
|
||||
<ChatInvite.Root code={code} hasFixedHeight>
|
||||
<ChatInviteEmbedBody link={link} onOpen={onOpen} style={style} />
|
||||
</ChatInvite.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatInviteEmbedBody({
|
||||
link,
|
||||
onOpen,
|
||||
style,
|
||||
}: {
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
onOpen?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {error} = ChatInvite.useChatInvite()
|
||||
|
||||
if (error) {
|
||||
return <ExternalEmbed link={link} onOpen={onOpen} style={style} />
|
||||
}
|
||||
|
||||
return <JoinRequestEmbedBody style={[a.mt_sm, style]} onOpen={onOpen} />
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {useMemo} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
@@ -51,17 +51,19 @@ export const ExternalEmbed = ({
|
||||
}, [link.uri, externalEmbedPrefs])
|
||||
const hasMedia = Boolean(imageUri || embedPlayerParams)
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
const onPress = () => {
|
||||
playHaptic('Light')
|
||||
onOpen?.()
|
||||
}, [playHaptic, onOpen])
|
||||
}
|
||||
|
||||
const onShareExternal = useCallback(() => {
|
||||
if (link.uri && IS_NATIVE) {
|
||||
playHaptic('Heavy')
|
||||
shareUrl(link.uri)
|
||||
}
|
||||
}, [link.uri, playHaptic])
|
||||
const onShareExternal = IS_NATIVE
|
||||
? () => {
|
||||
if (link.uri) {
|
||||
playHaptic('Heavy')
|
||||
void shareUrl(link.uri)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (
|
||||
embedPlayerParams?.source === 'tenor' ||
|
||||
@@ -108,6 +110,7 @@ export const ExternalEmbed = ({
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {Linking, View} from 'react-native'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Sparkle_Stroke2_Corner0_Rounded as Sparkle} from '#/components/icons/Sparkle'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
|
||||
/**
|
||||
* OTA-able fallback that ships to native builds which don't yet know how to
|
||||
* render the new gallery embed (>4 images, Photos v2). Final copy and visual
|
||||
* treatment pending design from Darrin/Danielle/Alex.
|
||||
*
|
||||
* Native-only per APP-2308 - web builds receive the new gallery support in
|
||||
* the same release that adds it.
|
||||
*/
|
||||
export function GalleryFallbackEmbed({count}: {count?: number}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const bodyStyle = [
|
||||
a.text_sm,
|
||||
a.text_center,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_high,
|
||||
]
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.mt_sm,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.p_lg,
|
||||
a.pb_2xl,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
{
|
||||
borderColor: t.palette.primary_200,
|
||||
backgroundColor: t.palette.primary_25,
|
||||
},
|
||||
]}>
|
||||
<Sparkle size="lg" fill={t.palette.primary_500} />
|
||||
<Text style={[a.text_md, a.font_bold, a.text_center, t.atoms.text]}>
|
||||
<Trans>Something new is here</Trans>
|
||||
</Text>
|
||||
{count ? (
|
||||
<View>
|
||||
<Text style={bodyStyle}>
|
||||
{plural(count, {
|
||||
one: 'This post has # photo.',
|
||||
other: 'This post has # photos.',
|
||||
})}
|
||||
</Text>
|
||||
{IS_NATIVE ? (
|
||||
<Text style={bodyStyle}>
|
||||
{plural(count, {
|
||||
one: 'Update your app to see it.',
|
||||
other: 'Update your app to see them all.',
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={bodyStyle}>
|
||||
{plural(count, {
|
||||
one: 'Refresh the page to see it.',
|
||||
other: 'Refresh the page to see them all.',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : IS_NATIVE ? (
|
||||
<Text style={bodyStyle}>
|
||||
<Trans>Update your app to see it.</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={bodyStyle}>
|
||||
<Trans>Refresh the page to see it.</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{IS_NATIVE && (
|
||||
<Button
|
||||
label={l`Update your app`}
|
||||
size="small"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
void Linking.openURL(BSKY_DOWNLOAD_URL)
|
||||
}}
|
||||
style={[a.mt_xs]}>
|
||||
<ButtonText>
|
||||
<Trans>Update app</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {type ReactNode} from 'react'
|
||||
import {type StyleProp, type ViewStyle} from 'react-native'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {shareImageModal} from '#/lib/media/manip'
|
||||
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
|
||||
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
||||
import * as PeekMenu from '#/components/PeekMenu'
|
||||
import {IS_IOS} from '#/env'
|
||||
|
||||
/**
|
||||
* Wraps an image embed with the iOS peek-and-menu interaction. On non-iOS
|
||||
* platforms this renders children unchanged.
|
||||
*
|
||||
* The aspect ratio is consumed by the native side to size the preview
|
||||
* viewController correctly — which is what makes the lift animation clean
|
||||
* for portrait/panorama images.
|
||||
*/
|
||||
export function ImageContextMenu({
|
||||
fullsizeUri,
|
||||
thumbUri,
|
||||
aspectRatio,
|
||||
borderRadius,
|
||||
onPreviewPress,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
fullsizeUri: string
|
||||
/** Thumbnail URL. Used as an instant placeholder in the native preview
|
||||
* while the fullsize loads, so there's no black flash on first peek. */
|
||||
thumbUri?: string
|
||||
/** width / height; defaults to 1 if missing. */
|
||||
aspectRatio: number | undefined
|
||||
borderRadius?: number
|
||||
onPreviewPress?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
children: ReactNode
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const saveImage = useSaveImageToMediaLibrary()
|
||||
|
||||
if (!IS_IOS) {
|
||||
return children
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
void saveImage(fullsizeUri)
|
||||
}
|
||||
const handleShare = () => {
|
||||
void shareImageModal({uri: fullsizeUri})
|
||||
}
|
||||
|
||||
return (
|
||||
<PeekMenu.Root style={style}>
|
||||
<PeekMenu.Trigger
|
||||
preview={{
|
||||
type: 'image',
|
||||
uri: fullsizeUri,
|
||||
thumbUri,
|
||||
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
|
||||
}}
|
||||
borderRadius={borderRadius}
|
||||
onPreviewPress={onPreviewPress}>
|
||||
{children}
|
||||
</PeekMenu.Trigger>
|
||||
<PeekMenu.Menu>
|
||||
<PeekMenu.MenuItem id="save" onSelect={handleSave}>
|
||||
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
|
||||
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
|
||||
</PeekMenu.MenuItem>
|
||||
<PeekMenu.MenuItem id="share" onSelect={handleShare}>
|
||||
<PeekMenu.MenuItemIcon icon={ShareIcon} />
|
||||
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
|
||||
</PeekMenu.MenuItem>
|
||||
</PeekMenu.Menu>
|
||||
</PeekMenu.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,66 @@
|
||||
import {useRef} from 'react'
|
||||
import {InteractionManager, View} from 'react-native'
|
||||
import {type AnimatedRef} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyEmbedGallery, type AppBskyEmbedImages} from '@atproto/api'
|
||||
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
import {Gallery} from '#/components/images/Gallery'
|
||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||
import {useLightboxControls} from '#/components/Lightbox/state'
|
||||
import {
|
||||
type LightboxMetricsContext,
|
||||
useLightboxControls,
|
||||
} from '#/components/Lightbox/state'
|
||||
import {type Dimensions} from '#/components/Lightbox/types'
|
||||
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {type EmbedType} from '#/types/bsky/post'
|
||||
import {type CommonProps} from './types'
|
||||
|
||||
const MAX_GRID_IMAGES = 4
|
||||
|
||||
export function ImageEmbed({
|
||||
embed,
|
||||
...rest
|
||||
}: CommonProps & {
|
||||
embed: EmbedType<'images'>
|
||||
embed: EmbedType<'images'> | EmbedType<'gallery'>
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {openLightbox} = useLightboxControls()
|
||||
const {images} = embed.view
|
||||
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||
const images: AppBskyEmbedImages.ViewImage[] =
|
||||
embed.type === 'gallery'
|
||||
? embed.view.items.filter(AppBskyEmbedGallery.isViewImage).map(item => ({
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
}))
|
||||
: embed.view.images
|
||||
const useExpandedLayout =
|
||||
embed.type === 'gallery'
|
||||
? images.length > MAX_GRID_IMAGES
|
||||
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||
|
||||
const layout: 'single' | 'grid' | 'carousel' =
|
||||
images.length === 1 ? 'single' : useExpandedLayout ? 'carousel' : 'grid'
|
||||
|
||||
const postContext = rest.post
|
||||
? {
|
||||
postUri: rest.post.uri,
|
||||
postAuthorDid: rest.post.author.did,
|
||||
feedDescriptor: rest.feedDescriptor,
|
||||
}
|
||||
: undefined
|
||||
const metricsContext: LightboxMetricsContext | undefined = postContext
|
||||
? {layout, ...postContext}
|
||||
: undefined
|
||||
|
||||
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
|
||||
// ref + dims that a tap would — keeps the lightbox's return animation intact.
|
||||
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
|
||||
const singleDimsRef = useRef<Dimensions | null>(null)
|
||||
|
||||
if (images.length > 0) {
|
||||
const items = images.map(img => ({
|
||||
@@ -36,6 +74,14 @@ export function ImageEmbed({
|
||||
refs: AnimatedRef<any>[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => {
|
||||
if (postContext) {
|
||||
ax.metric('post:photoEmbed:open', {
|
||||
layout,
|
||||
fromImage: index + 1,
|
||||
totalImages: images.length,
|
||||
...postContext,
|
||||
})
|
||||
}
|
||||
openLightbox({
|
||||
images: items.map((item, i) => ({
|
||||
...item,
|
||||
@@ -46,6 +92,7 @@ export function ImageEmbed({
|
||||
type: 'image',
|
||||
})),
|
||||
index,
|
||||
metricsContext,
|
||||
})
|
||||
}
|
||||
const onPressIn = (_: number) => {
|
||||
@@ -59,29 +106,50 @@ export function ImageEmbed({
|
||||
|
||||
if (images.length === 1) {
|
||||
const image = images[0]
|
||||
const aspect =
|
||||
image.aspectRatio && image.aspectRatio.height > 0
|
||||
? image.aspectRatio.width / image.aspectRatio.height
|
||||
: undefined
|
||||
const openFromSingle = () => {
|
||||
if (singleContainerRef.current) {
|
||||
onPress(0, [singleContainerRef.current], [singleDimsRef.current])
|
||||
}
|
||||
}
|
||||
return (
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<AutoSizedImage
|
||||
crop={
|
||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: rest.viewContext ===
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
|
||||
onPressIn={() => onPressIn(0)}
|
||||
hideBadge={
|
||||
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
/>
|
||||
<ImageContextMenu
|
||||
fullsizeUri={image.fullsize}
|
||||
thumbUri={image.thumb}
|
||||
aspectRatio={aspect}
|
||||
borderRadius={tokens.borderRadius.md}
|
||||
onPreviewPress={openFromSingle}>
|
||||
<AutoSizedImage
|
||||
crop={
|
||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: rest.isWithinQuote
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onContainerRef={ref => {
|
||||
singleContainerRef.current = ref
|
||||
}}
|
||||
onDimsChange={dims => {
|
||||
singleDimsRef.current = dims
|
||||
}}
|
||||
onPress={(containerRef, dims) =>
|
||||
onPress(0, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn(0)}
|
||||
hideBadge={rest.isWithinQuote}
|
||||
/>
|
||||
</ImageContextMenu>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (galleryEnabled) {
|
||||
if (useExpandedLayout) {
|
||||
return (
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<Gallery
|
||||
@@ -89,6 +157,8 @@ export function ImageEmbed({
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
viewContext={rest.viewContext}
|
||||
isWithinQuote={rest.isWithinQuote}
|
||||
metricsPostContext={postContext}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
@@ -101,6 +171,7 @@ export function ImageEmbed({
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
viewContext={rest.viewContext}
|
||||
isWithinQuote={rest.isWithinQuote}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
type ChatInvitePreview,
|
||||
isKnownJoinLinkPreview,
|
||||
} from '#/state/queries/join-links'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const JOIN_REQUEST_EMBED_HEIGHT = 140
|
||||
|
||||
/**
|
||||
* The "join request" presentation of a chat invite, used as a post embed (in
|
||||
* feeds and the post composer). Composes the headless `ChatInvite` primitive:
|
||||
* pass either a `code` to fetch by, or an already-resolved `preview` as the
|
||||
* initial data to avoid a loading flash.
|
||||
*/
|
||||
export function JoinRequestEmbed({
|
||||
code,
|
||||
preview,
|
||||
style,
|
||||
onOpen,
|
||||
}: {
|
||||
code?: string
|
||||
preview?: ChatInvitePreview
|
||||
style?: StyleProp<ViewStyle>
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const resolvedCode =
|
||||
code ?? (isKnownJoinLinkPreview(preview) ? preview.code : undefined)
|
||||
if (!resolvedCode) return null
|
||||
|
||||
return (
|
||||
<ChatInvite.Root
|
||||
code={resolvedCode}
|
||||
initialPreview={preview}
|
||||
hasFixedHeight>
|
||||
<JoinRequestEmbedBody style={style} onOpen={onOpen} />
|
||||
</ChatInvite.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The context-consuming presentation (loading / no-longer-available / card +
|
||||
* join button). Exported so surfaces that own their own `ChatInvite.Root` (e.g.
|
||||
* to add an error fallback) can render it without nesting another Root.
|
||||
*/
|
||||
export function JoinRequestEmbedBody({
|
||||
style,
|
||||
onOpen,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {loading, preview} = ChatInvite.useChatInvite()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.p_lg,
|
||||
a.border,
|
||||
a.rounded_lg,
|
||||
t.atoms.border_contrast_high,
|
||||
{height: JOIN_REQUEST_EMBED_HEIGHT},
|
||||
style,
|
||||
]}>
|
||||
<Loader size="md" fill={t.atoms.text.color} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.p_lg,
|
||||
a.gap_xs,
|
||||
a.border,
|
||||
a.rounded_lg,
|
||||
t.atoms.border_contrast_high,
|
||||
t.atoms.bg_contrast_25,
|
||||
{height: JOIN_REQUEST_EMBED_HEIGHT},
|
||||
style,
|
||||
]}>
|
||||
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
|
||||
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Chat invite link no longer available</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.justify_between,
|
||||
a.border,
|
||||
a.rounded_lg,
|
||||
a.p_lg,
|
||||
a.gap_lg,
|
||||
t.atoms.border_contrast_high,
|
||||
{height: JOIN_REQUEST_EMBED_HEIGHT},
|
||||
style,
|
||||
]}>
|
||||
<ChatInvite.Card size="large" />
|
||||
<ChatInvite.JoinButton onPress={onOpen} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import {Fragment, type ReactNode} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {StandardSite} from '#/components/icons/community/StandardSite'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {
|
||||
matchStandardSitePublisher,
|
||||
matchStandardSitePublisherByUri,
|
||||
@@ -18,19 +15,15 @@ import {
|
||||
isStandardSitePublicationUri,
|
||||
} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
export function StandardSiteMetaRow({
|
||||
type = 'document',
|
||||
preview,
|
||||
view,
|
||||
}: ssTypes.CommonProps &
|
||||
ssTypes.PreviewProps & {
|
||||
type?: 'document' | 'publication'
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const highlightedPublisher = !!matchStandardSitePublisher(view)
|
||||
const didsFromRecords =
|
||||
view.associatedRefs
|
||||
@@ -47,7 +40,11 @@ export function StandardSiteMetaRow({
|
||||
: undefined
|
||||
const articleDomain = toNiceDomain(view.uri)
|
||||
const articlePublisher = matchStandardSitePublisherByUri(view.uri)
|
||||
const DomainIcon = articlePublisher?.Icon || StandardSite
|
||||
const domainHandleMatch =
|
||||
authorProfile?.handle &&
|
||||
(articleDomain === authorProfile.handle ||
|
||||
articleDomain.endsWith(`.${authorProfile.handle}`))
|
||||
const DomainIcon = articlePublisher?.Icon
|
||||
const metaTextStyle = [
|
||||
a.text_xs,
|
||||
a.leading_tight,
|
||||
@@ -56,7 +53,7 @@ export function StandardSiteMetaRow({
|
||||
|
||||
const items: {key: string; node: ReactNode}[] = []
|
||||
|
||||
if (!highlightedPublisher) {
|
||||
if (!highlightedPublisher && !domainHandleMatch) {
|
||||
items.push({
|
||||
key: 'domain',
|
||||
node: (
|
||||
@@ -77,25 +74,7 @@ export function StandardSiteMetaRow({
|
||||
key: 'author',
|
||||
node: (
|
||||
<Text numberOfLines={1} style={[metaTextStyle]}>
|
||||
<Trans>
|
||||
by{' '}
|
||||
<InlineLinkText
|
||||
label={l`View @${authorProfile.handle}'s profile`}
|
||||
to={makeProfileLink(authorProfile)}
|
||||
style={[
|
||||
metaTextStyle,
|
||||
preview ? a.pointer_events_none : a.pointer_events_auto,
|
||||
]}
|
||||
onPress={e => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
ax.metric('embed:standardSite:authorHandle:press', {
|
||||
handle: authorProfile.handle,
|
||||
})
|
||||
}}>
|
||||
@{authorProfile.handle}
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
<Trans>by @{authorProfile.handle}</Trans>
|
||||
</Text>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useCallOnce} from '#/lib/once'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {niceDate} from '#/lib/strings/time'
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
@@ -16,6 +15,7 @@ import {Divider} from '#/components/Divider'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
|
||||
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
|
||||
import {StandardSite} from '#/components/icons/community/StandardSite'
|
||||
import {Link} from '#/components/Link'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
|
||||
@@ -80,13 +80,15 @@ export const StandardSiteEmbed = ({
|
||||
onEmbedInteractionCallback?.()
|
||||
ax.metric('embed:standardSite:article:press', {url: view.uri})
|
||||
}
|
||||
const onLongPress = () => {
|
||||
if (view.uri && IS_NATIVE) {
|
||||
playHaptic('Heavy')
|
||||
shareUrl(view.uri)
|
||||
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
|
||||
}
|
||||
}
|
||||
const onLongPress = IS_NATIVE
|
||||
? () => {
|
||||
if (view.uri) {
|
||||
playHaptic('Heavy')
|
||||
void shareUrl(view.uri)
|
||||
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
const onPressPublication = () => {
|
||||
playHaptic('Light')
|
||||
onEmbedInteractionCallback?.()
|
||||
@@ -94,21 +96,17 @@ export const StandardSiteEmbed = ({
|
||||
url: view.source?.uri || '',
|
||||
})
|
||||
}
|
||||
const onLongPressPublication = () => {
|
||||
if (view.source?.uri && IS_NATIVE) {
|
||||
playHaptic('Heavy')
|
||||
shareUrl(view.source.uri)
|
||||
ax.metric('embed:standardSite:publication:longPress', {
|
||||
url: view.source.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useCallOnce(() => {
|
||||
if (!preview) {
|
||||
ax.metric('embed:standardSite:view', {url: view.uri})
|
||||
}
|
||||
})()
|
||||
const onLongPressPublication = IS_NATIVE
|
||||
? () => {
|
||||
if (view.source?.uri) {
|
||||
playHaptic('Heavy')
|
||||
void shareUrl(view.source.uri)
|
||||
ax.metric('embed:standardSite:publication:longPress', {
|
||||
url: view.source.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (isStandardPublication) {
|
||||
return (
|
||||
@@ -166,6 +164,7 @@ export const StandardSiteEmbed = ({
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
@@ -355,6 +354,7 @@ export function PublicationCard({
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_md,
|
||||
@@ -385,7 +385,7 @@ export function PublicationCard({
|
||||
<View style={[a.pointer_events_none]}>
|
||||
{view.description && (
|
||||
<View style={[a.pt_sm]}>
|
||||
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
|
||||
<Text emoji style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
|
||||
{view.description}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -425,6 +425,26 @@ export function SubscribeButton({
|
||||
? l`Subscribe on ${highlightedPublisher.name}`
|
||||
: l`View publication`
|
||||
|
||||
/*
|
||||
* The custom site theme paints the button background with `accent` and the
|
||||
* text with `accentForeground`. Only honor it when that pairing clears WCAG
|
||||
* AAA (4.5:1) for large text, which the button's bold label qualifies as.
|
||||
* Otherwise we fall through to the default `secondary_inverted` styling,
|
||||
* which is guaranteed to be legible.
|
||||
*/
|
||||
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
|
||||
let useCustomTheme = false
|
||||
if (accentRGB && accentForegroundRGB) {
|
||||
const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
|
||||
const accentForeground = utils.rgbToHex(
|
||||
accentForegroundRGB.r,
|
||||
accentForegroundRGB.g,
|
||||
accentForegroundRGB.b,
|
||||
)
|
||||
const ratio = utils.contrastRatio(accent, accentForeground)
|
||||
useCustomTheme = ratio !== null && ratio >= 4.5
|
||||
}
|
||||
|
||||
if (!view.source) return null
|
||||
|
||||
const publicationTitle = view.source.title
|
||||
@@ -450,52 +470,60 @@ export function SubscribeButton({
|
||||
}
|
||||
}
|
||||
|
||||
const onLongPress = () => {
|
||||
if (view.source?.uri && IS_NATIVE) {
|
||||
playHaptic('Heavy')
|
||||
shareUrl(view.source.uri)
|
||||
if (highlightedPublisher) {
|
||||
ax.metric('embed:standardSite:subscribe:longPress', {
|
||||
url: view.source?.uri || '',
|
||||
})
|
||||
} else {
|
||||
ax.metric('embed:standardSite:publicationCta:longPress', {
|
||||
url: view.source?.uri || '',
|
||||
})
|
||||
const onLongPress = IS_NATIVE
|
||||
? () => {
|
||||
if (view.source?.uri) {
|
||||
playHaptic('Heavy')
|
||||
void shareUrl(view.source.uri)
|
||||
if (highlightedPublisher) {
|
||||
ax.metric('embed:standardSite:subscribe:longPress', {
|
||||
url: view.source?.uri || '',
|
||||
})
|
||||
} else {
|
||||
ax.metric('embed:standardSite:publicationCta:longPress', {
|
||||
url: view.source?.uri || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
||||
const button = (
|
||||
<Link
|
||||
shouldProxy
|
||||
to={view.source.uri}
|
||||
label={label}
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
style={[
|
||||
style,
|
||||
a.gap_sm,
|
||||
preview ? a.pointer_events_none : a.pointer_events_auto,
|
||||
]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}>
|
||||
{highlightedPublisher ? (
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
|
||||
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
|
||||
</View>
|
||||
<ButtonText>{cta}</ButtonText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ButtonText>{cta}</ButtonText>
|
||||
<ButtonIcon icon={ArrowTopRightIcon} />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
|
||||
if (!useCustomTheme) {
|
||||
return button
|
||||
}
|
||||
|
||||
return (
|
||||
<StandardSiteThemeProvider view={view}>
|
||||
<Link
|
||||
shouldProxy
|
||||
to={view.source.uri}
|
||||
label={label}
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
style={[
|
||||
style,
|
||||
a.gap_sm,
|
||||
preview ? a.pointer_events_none : a.pointer_events_auto,
|
||||
]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}>
|
||||
{highlightedPublisher ? (
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
|
||||
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
|
||||
</View>
|
||||
<ButtonText>{cta}</ButtonText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ButtonText>{cta}</ButtonText>
|
||||
<ButtonIcon icon={ArrowTopRightIcon} />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</StandardSiteThemeProvider>
|
||||
<StandardSiteThemeProvider view={view}>{button}</StandardSiteThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -508,8 +536,9 @@ function PublicationIcon({
|
||||
interacted?: boolean
|
||||
themeColors: ssTypes.ThemeColors
|
||||
}) {
|
||||
const t = useTheme()
|
||||
if (!view.source) return null
|
||||
return view.source?.icon ? (
|
||||
const icon = view.source?.icon ? (
|
||||
<View>
|
||||
<UserAvatar
|
||||
noBorder
|
||||
@@ -540,6 +569,29 @@ function PublicationIcon({
|
||||
<MediaInsetBorder opaque style={[a.rounded_sm]} />
|
||||
</View>
|
||||
)
|
||||
return (
|
||||
<View style={[a.relative]}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
a.z_10,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
t.atoms.bg,
|
||||
{
|
||||
width: 16,
|
||||
height: 16,
|
||||
top: -6,
|
||||
left: -6,
|
||||
},
|
||||
]}>
|
||||
<StandardSite size="xs" fill={t.atoms.text_contrast_medium.color} />
|
||||
<MediaInsetBorder />
|
||||
</View>
|
||||
{icon}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicationFooter({
|
||||
@@ -616,6 +668,7 @@ export function PublicationFooter({
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_sm,
|
||||
|
||||
@@ -108,10 +108,15 @@ export function useActiveVideoWeb() {
|
||||
|
||||
return {
|
||||
active: activeViewId === id,
|
||||
setActive: () => {
|
||||
setActive: useCallback(() => {
|
||||
setActiveView(id)
|
||||
},
|
||||
}, [setActiveView, id]),
|
||||
currentActiveView: activeViewId,
|
||||
sendPosition: (y: number) => sendViewPosition(id, y),
|
||||
sendPosition: useCallback(
|
||||
(y: number) => {
|
||||
sendViewPosition(id, y)
|
||||
},
|
||||
[sendViewPosition, id],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {Trans} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {getChatInviteCodeFromUrl} from '#/lib/strings/url-helpers'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
type EmbedType,
|
||||
parseEmbed,
|
||||
} from '#/types/bsky/post'
|
||||
import {ChatInviteEmbed} from './ChatInviteEmbed'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
import {ModeratedFeedEmbed} from './FeedEmbed'
|
||||
import {ImageEmbed} from './ImageEmbed'
|
||||
@@ -52,6 +54,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
|
||||
|
||||
switch (embed.type) {
|
||||
case 'images':
|
||||
case 'gallery':
|
||||
case 'link':
|
||||
case 'video': {
|
||||
return <MediaEmbed embed={embed} {...rest} />
|
||||
@@ -87,7 +90,8 @@ function MediaEmbed({
|
||||
embed: TEmbed
|
||||
}) {
|
||||
switch (embed.type) {
|
||||
case 'images': {
|
||||
case 'images':
|
||||
case 'gallery': {
|
||||
return (
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
@@ -110,6 +114,21 @@ function MediaEmbed({
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
const chatInviteCode = getChatInviteCodeFromUrl(embed.view.external.uri)
|
||||
if (chatInviteCode) {
|
||||
return (
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
activeStyle={[a.mt_sm]}>
|
||||
<ChatInviteEmbed
|
||||
code={chatInviteCode}
|
||||
link={embed.view.external}
|
||||
onOpen={rest.onOpen}
|
||||
style={rest.style}
|
||||
/>
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
@@ -326,6 +345,9 @@ export function QuoteEmbed({
|
||||
allowNestedQuotes={
|
||||
parentIsWithinQuote ? false : parentAllowNestedQuotes
|
||||
}
|
||||
// The photo embed belongs to the quoted post, so attribute its
|
||||
// analytics to the quoted post rather than the parent.
|
||||
post={quote}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,13 @@ export type CommonProps = {
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
allowNestedQuotes?: boolean
|
||||
/**
|
||||
* The post that contains this embed. Used for analytics on photo embed
|
||||
* events (post:photoEmbed:*). When the embed has no owning post (e.g.
|
||||
* composer previews), leave this undefined and no events will be emitted.
|
||||
*/
|
||||
post?: AppBskyFeedDefs.PostView
|
||||
feedDescriptor?: string
|
||||
}
|
||||
|
||||
export type EmbedProps = CommonProps & {
|
||||
|
||||
@@ -88,6 +88,7 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {BlockDialog} from '#/components/moderation/BlockDialog'
|
||||
import {
|
||||
ReportDialog,
|
||||
useReportDialogControl,
|
||||
@@ -845,13 +846,10 @@ let PostMenuItems = ({
|
||||
onConfirm={() => void onToggleReplyVisibility()}
|
||||
confirmButtonCta={l`Yes, hide`}
|
||||
/>
|
||||
<Prompt.Basic
|
||||
<BlockDialog
|
||||
control={blockPromptControl}
|
||||
title={l`Block Account?`}
|
||||
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
|
||||
onConfirm={() => void onBlockAuthor()}
|
||||
confirmButtonCta={l`Block`}
|
||||
confirmButtonColor="negative"
|
||||
profile={postAuthor}
|
||||
onBlock={onBlockAuthor}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -152,11 +152,7 @@ function RecentChatItem({
|
||||
a.align_center,
|
||||
]}>
|
||||
{convo.kind === 'group' ? (
|
||||
<AvatarBubbles
|
||||
profiles={convo.members}
|
||||
size={WIDTH - 8}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<AvatarBubbles profiles={convo.members} size={WIDTH - 8} />
|
||||
) : (
|
||||
<UserAvatar
|
||||
avatar={primaryProfile.avatar}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user