Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da815b960b | |||
| 308693762c | |||
| fcf17a482f | |||
| 6b342f61b4 | |||
| eae15aca3e | |||
| 29a0d16b69 | |||
| c33a35851e | |||
| b1b0ad09cc | |||
| 46f5437faa | |||
| 3b2e609761 | |||
| 1ff27bfd3b | |||
| f72aae3bac | |||
| 51e25435a2 | |||
| da1f637160 | |||
| f64b1258b6 | |||
| c909fef3f1 | |||
| 5b66015631 | |||
| 90c010e98a | |||
| f3978d1a1f | |||
| 2843374ef8 | |||
| 0674626f3c | |||
| cd4d62c75a | |||
| c3626c80d1 | |||
| e371d1db44 | |||
| 4308d75e49 | |||
| ea0ef23340 | |||
| 692d8e5d10 | |||
| c753c94969 | |||
| cc0e1f88ea | |||
| 495d1fe9a7 | |||
| dd452a4336 | |||
| 1a8ca710ee | |||
| 6f916902e7 | |||
| d10137fd78 | |||
| 2347d4f074 | |||
| b368cea557 | |||
| 728ee15e27 | |||
| 6af477bcc2 | |||
| 1b5242c1d6 | |||
| 6bf1572ffc | |||
| c3a110a939 | |||
| f92712f34d | |||
| 9825057bef | |||
| e057d3bd54 | |||
| b90d3108ed | |||
| 255af85428 | |||
| b8a6a8c8ce | |||
| 4c34149106 | |||
| 2257e834dc | |||
| 8d4f59a146 | |||
| 721965e67c | |||
| bb6847f396 | |||
| b8fdce6478 | |||
| f075187e57 | |||
| 9dc73d2ec7 | |||
| 1c2fd5b280 | |||
| d7e285aa24 | |||
| fdd08330f7 | |||
| 042fdf659d | |||
| 6dcb4c9a1f | |||
| d20d021173 | |||
| 0204848cc4 | |||
| 02a47fb4a6 | |||
| 575b76a71e | |||
| feff61a031 | |||
| e1f86e49e7 | |||
| 6028532981 | |||
| 825d25847c | |||
| cd404023fb | |||
| 52b945b7be | |||
| 9c346e5494 | |||
| 714b1e205d | |||
| 1f90a4dfd9 | |||
| 7310426ea7 | |||
| 581d2b296d | |||
| ec695a42c5 | |||
| 6c04170734 | |||
| bce0b51c2e | |||
| 0a9f7423e2 | |||
| f0e20d4c56 | |||
| b281e23f11 | |||
| bd775070e8 | |||
| 3d3d7ba675 | |||
| f96e6ab985 | |||
| d57eeb986b | |||
| 91053e3f70 | |||
| b0eaecb01b | |||
| c96d63fa5f | |||
| bd034ac1a8 | |||
| ba72110a12 | |||
| 302026d684 | |||
| c8634105c1 | |||
| e3e7db2350 | |||
| fbd81ad025 | |||
| 5c04a41a11 | |||
| 0187a49534 | |||
| d3f8e740de | |||
| b4dff0f4b7 | |||
| 871e9506df | |||
| 974d2b15d5 | |||
| b667cafca1 | |||
| 156903070b | |||
| 3feea02548 | |||
| 2efdc3fe35 | |||
| 2d896983ab | |||
| b47948dca7 | |||
| 8db30c7d31 | |||
| 3b5ede37f7 | |||
| 3095bbfbec | |||
| b3985bfad5 | |||
| c6f9803676 | |||
| adb39b4442 | |||
| 01054624ef | |||
| 8c8617d5f2 | |||
| 50dabf3f65 | |||
| e5335fa52b | |||
| 9e9a4024c3 | |||
| 209ecafdc1 | |||
| 90f8fe59ba | |||
| a69836216c | |||
| 8fcf25b290 | |||
| 8b4a5238be | |||
| 17ff9af200 | |||
| 7379ad3c3b | |||
| 9a2e636c17 | |||
| f321122656 | |||
| d6a14ade54 | |||
| 8720d05242 | |||
| fc14427134 | |||
| 76694a2ba8 | |||
| 38fb9c2165 | |||
| db2e190f4c | |||
| 7f6cfaee71 | |||
| 0558e8b79a | |||
| 852cb53cab | |||
| c27f50e038 | |||
| 189dad8cb7 | |||
| de5ae04809 | |||
| 6ba5154bcc | |||
| 994e27ded1 | |||
| df8c731f1d | |||
| 3fdfebfe29 | |||
| 750c55eff8 | |||
| 986cf62da1 | |||
| be30947089 | |||
| fb4133cdc7 | |||
| e339a2a18c | |||
| c52231a449 | |||
| 4c4ebd0510 | |||
| bc94c8c07b | |||
| 22643c76d0 | |||
| 84c4223ff9 | |||
| 2cb225dddd | |||
| 8015c26955 | |||
| c6e6b83d50 | |||
| bfbd1cef32 | |||
| 690b8184a1 | |||
| 46e54d2375 | |||
| 38ee14d517 | |||
| 1c5b3dd3a1 | |||
| 0c89198ded | |||
| fe9dabbc4a | |||
| 4142eb4834 | |||
| 9b5fc19613 | |||
| 0090285fc0 | |||
| c4f3a2cb89 | |||
| d9345cb1cf |
@@ -0,0 +1,63 @@
|
||||
You are an experienced senior React Native engineer reviewing a pull
|
||||
request in the Bluesky Social app — a cross-platform (iOS, Android, Web)
|
||||
React Native + Expo application. Read the repo's CLAUDE.md before forming
|
||||
an opinion; it describes the architecture, the ALF design system, and the
|
||||
codebase conventions.
|
||||
|
||||
Your audience is other senior engineers. Write peer-to-peer, not
|
||||
teacher-to-junior. Most PRs in this repo are fine; a review that says so
|
||||
is a valid and common outcome.
|
||||
|
||||
Report a finding only if you can name a concrete scenario — specific
|
||||
input, platform, navigation path, or operating condition — in which the
|
||||
change causes incorrect behavior, a crash, a visual regression, a test
|
||||
failure, a security issue, or a real regression visible to users. Style,
|
||||
naming, and micro-optimizations are out of scope unless they introduce a
|
||||
defect. Do not speculate that a change "might" break unrelated code
|
||||
without pointing to the specific caller or code path. Do not repeat what
|
||||
the diff does.
|
||||
|
||||
Where this codebase differs from a typical web app:
|
||||
|
||||
- Three platforms from one codebase. Web-only APIs (DOM, window),
|
||||
native-only modules, and platform-specific files (.web.tsx, .ios.tsx,
|
||||
.android.tsx) are common sources of single-platform breakage. When a
|
||||
change touches shared code, consider all three targets.
|
||||
- User-facing strings must go through Lingui (the `Trans` macro /
|
||||
`useLingui`). Hardcoded English strings in UI are a finding. Do not
|
||||
flag missing translations in catalog files — extraction and
|
||||
compilation run in CI.
|
||||
- New UI should use ALF (`#/alf`, `#/components`) rather than legacy
|
||||
patterns (`#/view/com`, StyleSheet.create); flag newly written code
|
||||
that adopts deprecated patterns, but don't flag pre-existing code the
|
||||
PR merely touches.
|
||||
- Server state lives in TanStack Query under src/state/queries. Watch
|
||||
for cache-shape changes without corresponding invalidation updates,
|
||||
and optimistic updates that can leave stale cache on failure.
|
||||
- List rendering is performance-critical (the main feed). Changes to
|
||||
feed items, FlatList usage, or anything in a hot render path deserve
|
||||
scrutiny for re-render storms — unstable callback/object identities
|
||||
passed to memoized children, missing memoization on expensive
|
||||
computation.
|
||||
- Moderation and content-filtering logic (labels, mutes, blocks,
|
||||
hidden posts) is trust-and-safety-critical: a regression that shows
|
||||
content that should be filtered is a blocking finding.
|
||||
- Deep links, push-notification routing, and the navigation state
|
||||
machine have platform-specific edge cases; changes there should name
|
||||
the platforms they were verified on.
|
||||
- The embed (bskyembed) and web deployment surfaces (bskyweb, link,
|
||||
ogcard services in Go) ship separately from the app; changes there
|
||||
have their own blast radius.
|
||||
|
||||
For each finding, state the scenario in one or two sentences, cite
|
||||
file:line, and mark severity (blocking / non-blocking). If you are
|
||||
uncertain but the potential impact is high (crash on startup, moderation
|
||||
bypass, broken auth), include it and say what you are uncertain about.
|
||||
Otherwise, prefer silence over guessing.
|
||||
|
||||
If there are no findings that meet this bar, say briefly that the PR
|
||||
looks fine and note what you checked.
|
||||
|
||||
Post your review as a single top-level PR comment. Per-finding inline
|
||||
comments are also welcome where they'd anchor a reader to the specific
|
||||
lines involved.
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME }}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -10,6 +10,23 @@ on:
|
||||
options:
|
||||
- testflight-android
|
||||
- production
|
||||
workflow_call:
|
||||
inputs:
|
||||
profile:
|
||||
type: string
|
||||
description: Build profile to use
|
||||
required: true
|
||||
outputs:
|
||||
package-version:
|
||||
description: Version from package.json
|
||||
value: ${{ jobs.build.outputs.package-version }}
|
||||
version-code:
|
||||
description: Android version code
|
||||
value: ${{ jobs.build.outputs.version-code }}
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -19,6 +36,10 @@ jobs:
|
||||
concurrency:
|
||||
group: android-build
|
||||
cancel-in-progress: false
|
||||
outputs:
|
||||
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
||||
version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
|
||||
apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -28,32 +49,32 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -62,7 +83,8 @@ jobs:
|
||||
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
|
||||
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
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: Env
|
||||
@@ -92,99 +114,76 @@ jobs:
|
||||
--profile $PROFILE
|
||||
--local --output build.aab --non-interactive
|
||||
|
||||
- name: ✍️ Rename Testflight bundle
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
run: mv build.aab build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
uses: nanzm/get-time-action@master
|
||||
with:
|
||||
format: "MM-DD-HH-mm-ss"
|
||||
|
||||
- name: 🚀 Upload Production Artifact
|
||||
id: upload-artifact-production
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.aab
|
||||
path: build.aab
|
||||
|
||||
- name: 🚀 Upload Testflight Artifact
|
||||
id: upload-artifact-testflight
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 📚 Get version from package.json
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
- name: 🚀 Submit to Google Play
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight-android' }}
|
||||
run: pnpm eas submit -p android --profile $PROFILE --non-interactive --path build.aab
|
||||
|
||||
- name: 🔔 Notify Slack of Play Store Submission
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android production build for Google Play Store submission is ready!\n```Artifact: ${{ steps.upload-artifact-production.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
|
||||
{"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
|
||||
|
||||
- name: 🔔 Notify Slack of Testflight Build
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
- name: 🔧 Setup bundletool
|
||||
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact-testflight.outputs.artifact-url }}"}
|
||||
bundletool-version: "1.17.2"
|
||||
|
||||
- name: 🧹 Clear Metro cache
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
# https://github.com/expo/eas-cli/issues/2959#issuecomment-2749791326
|
||||
run: rm -rf ${TMPDIR:-/tmp}/metro-cache
|
||||
- name: 🔑 Decode keystore
|
||||
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
|
||||
keystore.jks
|
||||
|
||||
- name: 🏗️ Build Production APK
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p android
|
||||
--profile production-apk
|
||||
--local --output build.apk --non-interactive
|
||||
- name: 📦 Build signed universal APK
|
||||
run: |
|
||||
bundletool build-apks \
|
||||
--bundle=build.aab \
|
||||
--output=universal.apks \
|
||||
--mode=universal \
|
||||
--ks=keystore.jks \
|
||||
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
|
||||
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
|
||||
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
- name: 🚀 Upload Production APK Artifact
|
||||
id: upload-artifact-production-apk
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
- name: 📋 Rename to .zip for extraction
|
||||
run: mv universal.apks universal.zip
|
||||
|
||||
- name: 📦 Extract universal APK
|
||||
run: unzip -p universal.zip universal.apk > build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🚀 Upload APK Artifact
|
||||
id: upload-artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack of Production APK Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
- name: 🔔 Notify Slack of APK Artifact
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android production build for GitHub/Obtanium is ready!\n```Artifact: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
|
||||
{"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
@@ -195,3 +194,62 @@ jobs:
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
# Releases are cut from tags named after the version (e.g. "1.124.0"), so when a production
|
||||
# build is dispatched against such a tag we attach the APK to the matching release. This runs
|
||||
# as a separate job so that `contents: write` is isolated here and the build job stays read-only.
|
||||
attachToRelease:
|
||||
name: Attach APK to GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
if: ${{ inputs.profile == 'production' && github.ref_type == 'tag' && github.repository == 'bluesky-social/social-app' }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# We only attach to a release that already exists — never create one.
|
||||
- name: 🔎 Check for matching GitHub Release
|
||||
id: release-check
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
status=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "Found GitHub Release for tag $TAG"
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No GitHub Release found for tag $TAG (HTTP $status); skipping APK attachment."
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: ⬇️ Download APK artifact
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: ${{ needs.build.outputs.apk-artifact-name }}
|
||||
|
||||
- name: 🏷️ Rename APK for release
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
run: cp build.apk "Bluesky-${{ needs.build.outputs.package-version }}.apk"
|
||||
|
||||
- name: 📎 Attach APK to GitHub Release
|
||||
id: attach
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: Bluesky-${{ needs.build.outputs.package-version }}.apk
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
- name: 🔔 Notify Slack of Release Attachment
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android APK attached to GitHub Release ${{ github.ref_name }}!\n```Asset: Bluesky-${{ needs.build.outputs.package-version }}.apk\nRelease: ${{ steps.attach.outputs.url }}```"}
|
||||
|
||||
@@ -10,6 +10,35 @@ on:
|
||||
options:
|
||||
- testflight
|
||||
- production
|
||||
testFlightGroup:
|
||||
type: choice
|
||||
description: TestFlight group to assign the build to after submitting
|
||||
options:
|
||||
- none
|
||||
- QA Team
|
||||
- Software Mansion
|
||||
default: none
|
||||
workflow_call:
|
||||
inputs:
|
||||
profile:
|
||||
type: string
|
||||
description: Build profile to use
|
||||
required: true
|
||||
testFlightGroup:
|
||||
type: string
|
||||
description: TestFlight group to assign the build to after submitting ("none" to skip)
|
||||
default: none
|
||||
outputs:
|
||||
package-version:
|
||||
description: Version from package.json
|
||||
value: ${{ jobs.build.outputs.package-version }}
|
||||
build-number:
|
||||
description: iOS build number
|
||||
value: ${{ jobs.build.outputs.build-number }}
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -19,6 +48,9 @@ jobs:
|
||||
concurrency:
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
outputs:
|
||||
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
||||
build-number: ${{ steps.ipa-build-number.outputs.build-number }}
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -28,42 +60,48 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
with:
|
||||
version: 1.16.2
|
||||
- name: ☕️ Assert Cocoapods version
|
||||
run: |
|
||||
EXPECTED=1.16.2
|
||||
ACTUAL=$(pod --version)
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||
echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md"
|
||||
echo "If the runner image changed, update EXPECTED here or reinstall the pinned version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
@@ -75,7 +113,8 @@ jobs:
|
||||
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
|
||||
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
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: ✏️ Write environment variables
|
||||
@@ -153,19 +192,76 @@ jobs:
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
|
||||
# Read the build number straight from the IPA's CFBundleVersion. This is the value
|
||||
# baked in at build time by use-build-number-with-bump (remote counter + 1) and the
|
||||
# number that actually lands in App Store Connect. `eas build:version:get` reads the
|
||||
# remote counter, which a --local build does not advance, so it can be off by one —
|
||||
# using it here would make distribute_only poll for a nonexistent build.
|
||||
- name: 🔢 Read build number from IPA
|
||||
id: ipa-build-number
|
||||
run: |
|
||||
plist_dir="$(mktemp -d)"
|
||||
unzip -o -q "$BUILD_DIR/Bluesky.ipa" 'Payload/*.app/Info.plist' -d "$plist_dir"
|
||||
plist="$(find "$plist_dir" -name Info.plist -print -quit)"
|
||||
build_number="$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$plist")"
|
||||
rm -rf "$plist_dir"
|
||||
if [ -z "$build_number" ]; then
|
||||
echo "ERROR: could not read CFBundleVersion from IPA"
|
||||
exit 1
|
||||
fi
|
||||
echo "IPA build number: $build_number"
|
||||
echo "build-number=$build_number" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# eas submit only uploads to App Store Connect; it can't assign a build to a
|
||||
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
|
||||
# already-submitted build to the group, polling until Apple finishes processing it.
|
||||
- name: 🧪 Assign build to TestFlight group
|
||||
if: ${{ inputs.testFlightGroup != 'none' }}
|
||||
env:
|
||||
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
|
||||
APP_VERSION: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
||||
BUILD_NUMBER: ${{ steps.ipa-build-number.outputs.build-number }}
|
||||
run: |
|
||||
# Ensure the API key material is removed even if fastlane exits non-zero
|
||||
# (the step runs under `bash -e`, which would otherwise abort before cleanup).
|
||||
trap 'rm -f asc_api_key.json' EXIT
|
||||
# fastlane's Token.from_json_file expects the .p8 contents inline under "key"
|
||||
# (PEM with embedded newlines), not a path. jq handles the newline escaping.
|
||||
key_content="$(echo "$ASC_KEY_P8_BASE64" | base64 --decode)"
|
||||
jq -n \
|
||||
--arg key_id "$ASC_KEY_ID" \
|
||||
--arg issuer_id "$ASC_ISSUER_ID" \
|
||||
--arg key "$key_content" \
|
||||
'{key_id: $key_id, issuer_id: $issuer_id, key: $key, in_house: false}' \
|
||||
> asc_api_key.json
|
||||
# app_platform is required in non-interactive mode: distribute_only otherwise
|
||||
# calls fetch_app_platform, which prompts for input and crashes without a TTY.
|
||||
fastlane run upload_to_testflight \
|
||||
api_key_path:"$PWD/asc_api_key.json" \
|
||||
distribute_only:true \
|
||||
app_platform:"ios" \
|
||||
app_identifier:"xyz.blueskyweb.app" \
|
||||
app_version:"$APP_VERSION" \
|
||||
build_number:"$BUILD_NUMBER" \
|
||||
groups:"$TESTFLIGHT_GROUP" \
|
||||
notify_external_testers:true
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_IOS_BUILD_NUMBER }}```"}
|
||||
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.ipa-build-number.outputs.build-number }}```"}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
|
||||
@@ -18,6 +18,10 @@ on:
|
||||
description: Runtime version (in x.x.x format) that this update is for
|
||||
required: true
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
bundleDeploy:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
@@ -48,7 +52,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -56,17 +60,17 @@ jobs:
|
||||
if: ${{ github.ref != 'refs/heads/main' }}
|
||||
run: git fetch origin main:main --depth 100
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
uses: bluesky-social/github-actions/fingerprint-native@main
|
||||
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
|
||||
with:
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
||||
@@ -75,7 +79,8 @@ jobs:
|
||||
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
|
||||
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
|
||||
@@ -87,7 +92,7 @@ jobs:
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
@@ -96,7 +101,7 @@ jobs:
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
# eas.json not used here, set EXPO_PUBLIC_ENV
|
||||
- name: Env
|
||||
@@ -137,7 +142,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
@@ -157,7 +162,9 @@ jobs:
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
# available here
|
||||
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app' }}
|
||||
if: ${{ inputs.channel != 'production' &&
|
||||
needs.bundleDeploy.outputs.changes-detected && github.repository ==
|
||||
'bluesky-social/social-app' }}
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -167,20 +174,20 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -189,17 +196,23 @@ jobs:
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
with:
|
||||
version: 1.16.2
|
||||
- name: ☕️ Assert Cocoapods version
|
||||
run: |
|
||||
EXPECTED=1.16.2
|
||||
ACTUAL=$(pod --version)
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||
echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md"
|
||||
echo "If the runner image changed, update EXPECTED here or reinstall the pinned version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
@@ -282,7 +295,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ inputs.channel == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
@@ -303,7 +316,9 @@ jobs:
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
# available here
|
||||
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app'}}
|
||||
if: ${{ inputs.channel != 'production' &&
|
||||
needs.bundleDeploy.outputs.changes-detected && github.repository ==
|
||||
'bluesky-social/social-app'}}
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
@@ -314,26 +329,26 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -368,17 +383,49 @@ jobs:
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p android
|
||||
--profile testflight-android
|
||||
--local --output build.apk --non-interactive
|
||||
--local --output build.aab --non-interactive
|
||||
|
||||
- name: 📚 Get version from package.json
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
|
||||
- name: 🚀 Submit to Google Play
|
||||
run: pnpm eas submit -p android --profile testflight-android --non-interactive --path
|
||||
build.aab
|
||||
|
||||
- name: 🔧 Setup bundletool
|
||||
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
|
||||
with:
|
||||
bundletool-version: "1.17.2"
|
||||
|
||||
- name: 🔑 Decode keystore
|
||||
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
|
||||
keystore.jks
|
||||
|
||||
- name: 📦 Build signed universal APK
|
||||
run: |
|
||||
bundletool build-apks \
|
||||
--bundle=build.aab \
|
||||
--output=universal.apks \
|
||||
--mode=universal \
|
||||
--ks=keystore.jks \
|
||||
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
|
||||
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
|
||||
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
- name: 📋 Rename to .zip for extraction
|
||||
run: mv universal.apks universal.zip
|
||||
|
||||
- name: 📦 Extract universal APK
|
||||
run: unzip -p universal.zip universal.apk > build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
uses: nanzm/get-time-action@master
|
||||
with:
|
||||
format: "MM-DD-HH-mm-ss"
|
||||
run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🚀 Upload Artifact
|
||||
id: upload-artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
@@ -386,7 +433,7 @@ jobs:
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
@@ -396,7 +443,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: claude-mention
|
||||
|
||||
# @claude mention handler on PR conversation comments, inline review
|
||||
# comments, and review bodies. See the header comment in
|
||||
# claude-review.yml for why this is self-contained rather than calling
|
||||
# the org reusable workflows.
|
||||
#
|
||||
# Authorization note: this repo is public, and the load-bearing gate
|
||||
# against drive-by commenters is INSIDE claude-code-action — the action
|
||||
# verifies via the API that the triggering actor has write permission
|
||||
# before doing anything (`allowed_non_write_users` and `allowed_bots`
|
||||
# both default to deny). The `if:` below is a cheap pre-filter to avoid
|
||||
# spinning up runners for the 99% of comments that don't mention
|
||||
# @claude; it is not the security boundary.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
mention:
|
||||
# Skip comments/reviews that don't mention @claude, non-PR issue
|
||||
# comments, and events from claude[bot] itself (its review prose
|
||||
# often quotes "@claude" and must not retrigger the workflow).
|
||||
if: >
|
||||
(
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' &&
|
||||
contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@claude'))
|
||||
) &&
|
||||
github.actor != 'claude[bot]'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
concurrency:
|
||||
group: claude-mention-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude
|
||||
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
track_progress: true
|
||||
claude_args: |
|
||||
--model global.anthropic.claude-opus-4-8
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,mcp__github_ci__get_ci_status,mcp__github_ci__download_job_log,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||
@@ -0,0 +1,68 @@
|
||||
name: claude-review
|
||||
|
||||
# Automatic Claude review on PR creation/update, via Bedrock (OIDC, no
|
||||
# long-lived tokens). Self-contained: this intentionally uses upstream
|
||||
# claude-code-action defaults rather than the org reusable workflows in
|
||||
# bluesky-social/.github (which a public repo cannot call, and whose
|
||||
# customizations added no value over upstream).
|
||||
#
|
||||
# Review guidance lives in .github/claude-review-prompt.md.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
review:
|
||||
# Internal PRs only. This repo is public: fork PRs are the bulk of
|
||||
# community traffic and MUST NOT trigger reviews (no Bedrock spend on
|
||||
# unvetted code, and fork PRs can't mint the OIDC token anyway —
|
||||
# belt-and-braces with this explicit guard). Branch PRs can only be
|
||||
# created by people with write access, i.e. org members.
|
||||
# Bot-authored PRs (dependabot, changesets) are also skipped.
|
||||
if: >
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.type != 'Bot'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
concurrency:
|
||||
group: claude-review-${{ github.repository }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude review
|
||||
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
track_progress: true
|
||||
claude_args: |
|
||||
--model global.anthropic.claude-opus-4-8
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,mcp__github_ci__get_ci_status,mcp__github_ci__download_job_log,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Read .github/claude-review-prompt.md in the checked-out repo
|
||||
and review this pull request following its guidance.
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
# NOTE(sfn): we can add a custom system prompt here
|
||||
|
||||
claude_args: |
|
||||
--model claude-opus-4-7
|
||||
@@ -10,14 +10,17 @@ concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
@@ -33,9 +36,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
job: [lint, prettier, typecheck]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Verify Node version pins match package.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -50,9 +50,9 @@ jobs:
|
||||
v=$(grep -oE '"node":[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' eas.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
|
||||
check "eas.json" "$v"
|
||||
exit $rc
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -87,10 +87,10 @@ jobs:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: Nightly Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "10 2 * * *" # run at 2:10 AM UTC, after the nightly i18n job
|
||||
workflow_dispatch:
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Generate the changelog once, shared by both platforms. The range covers commits since
|
||||
# the previous nightly, whose commit SHA is stored as a "nightly-build-commit" artifact
|
||||
# (advanced by the record job below, only after both builds succeed).
|
||||
prepare:
|
||||
name: Prepare release notes
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
notes: ${{ steps.notes.outputs.notes }}
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 📝 Generate release notes
|
||||
id: notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Find the most recent non-expired nightly-build-commit artifact and read its SHA
|
||||
# The artifacts API returns results newest-first, so the most recent marker is on
|
||||
# page 1 — no --paginate needed (which would run the jq aggregation per page and
|
||||
# could emit multiple URLs). Take the first non-expired match.
|
||||
prev=""
|
||||
url=$(gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/artifacts?name=nightly-build-commit&per_page=100" \
|
||||
--jq 'first(.artifacts[] | select(.expired == false)) | .archive_download_url' \
|
||||
2>/dev/null || true)
|
||||
if [ -n "$url" ] && [ "$url" != "null" ]; then
|
||||
if curl -sSL -H "Authorization: Bearer $GH_TOKEN" -o marker.zip "$url" \
|
||||
&& unzip -o -q marker.zip; then
|
||||
prev=$(cat nightly-build-commit.txt 2>/dev/null | tr -d '[:space:]')
|
||||
fi
|
||||
rm -f marker.zip nightly-build-commit.txt
|
||||
fi
|
||||
|
||||
if [ -n "$prev" ] && git cat-file -e "${prev}^{commit}" 2>/dev/null; then
|
||||
echo "Generating notes since previous nightly: $prev"
|
||||
range="${prev}..HEAD"
|
||||
else
|
||||
echo "No reachable previous nightly commit; falling back to last 30 commits."
|
||||
range="HEAD~30..HEAD"
|
||||
fi
|
||||
notes=$(git log --no-merges --pretty=format:'- %s' "$range" 2>/dev/null | head -n 50)
|
||||
if [ -z "$notes" ]; then
|
||||
notes="Nightly build — no new commits since the last nightly."
|
||||
fi
|
||||
# Cap the whole changelog to keep the Slack message a reasonable size.
|
||||
# head -c caps the combined stream; cut -c would only cap each line independently.
|
||||
notes=$(printf '%s' "$notes" | head -c 3900)
|
||||
{
|
||||
echo "notes<<NOTES_EOF"
|
||||
echo "$notes"
|
||||
echo "NOTES_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
ios:
|
||||
name: Nightly iOS Build
|
||||
needs: [prepare]
|
||||
uses: ./.github/workflows/build-submit-ios.yml
|
||||
with:
|
||||
profile: testflight
|
||||
testFlightGroup: "QA Team"
|
||||
secrets: inherit
|
||||
|
||||
android:
|
||||
name: Nightly Android Build
|
||||
needs: [prepare]
|
||||
# build-submit-android.yml contains an attachToRelease job that requests contents: write.
|
||||
# That job is skipped for nightly (it needs a production tag build), but GitHub statically
|
||||
# validates the reusable-workflow permission ceiling, so the caller must grant it here.
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/build-submit-android.yml
|
||||
with:
|
||||
profile: testflight-android
|
||||
secrets: inherit
|
||||
|
||||
notify-ios:
|
||||
name: Notify Slack of iOS nightly
|
||||
needs: [prepare, ios]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 📝 Build Slack payload
|
||||
id: payload
|
||||
env:
|
||||
NOTES: ${{ needs.prepare.outputs.notes }}
|
||||
VERSION: ${{ needs.ios.outputs.package-version }}
|
||||
BUILD_NUMBER: ${{ needs.ios.outputs.build-number }}
|
||||
run: |
|
||||
text="*Nightly iOS build available in TestFlight (QA Team)*
|
||||
Version ${VERSION} (${BUILD_NUMBER})
|
||||
|
||||
${NOTES}"
|
||||
payload=$(jq -n --arg text "$text" '{text: $text}')
|
||||
{
|
||||
echo "payload<<PAYLOAD_EOF"
|
||||
echo "$payload"
|
||||
echo "PAYLOAD_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: ${{ steps.payload.outputs.payload }}
|
||||
|
||||
notify-android:
|
||||
name: Notify Slack of Android nightly
|
||||
needs: [prepare, android]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 📝 Build Slack payload
|
||||
id: payload
|
||||
env:
|
||||
NOTES: ${{ needs.prepare.outputs.notes }}
|
||||
VERSION: ${{ needs.android.outputs.package-version }}
|
||||
VERSION_CODE: ${{ needs.android.outputs.version-code }}
|
||||
run: |
|
||||
text="*Nightly Android build available (Internal track)*
|
||||
Version ${VERSION} (${VERSION_CODE})
|
||||
|
||||
${NOTES}"
|
||||
payload=$(jq -n --arg text "$text" '{text: $text}')
|
||||
{
|
||||
echo "payload<<PAYLOAD_EOF"
|
||||
echo "$payload"
|
||||
echo "PAYLOAD_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: ${{ steps.payload.outputs.payload }}
|
||||
|
||||
# Advance the nightly marker only after both builds succeed, so a failed night's commits
|
||||
# roll into the next successful nightly's notes rather than being silently dropped.
|
||||
record:
|
||||
name: Record nightly commit
|
||||
needs: [ios, android]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ✏️ Write nightly commit marker
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo "$GITHUB_SHA" > nightly-build-commit.txt
|
||||
|
||||
- name: 🚀 Upload nightly commit marker
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: nightly-build-commit
|
||||
path: nightly-build-commit.txt
|
||||
retention-days: 90
|
||||
@@ -16,12 +16,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -30,12 +30,12 @@ jobs:
|
||||
- name: Extract language strings
|
||||
run: pnpm intl:extract
|
||||
- name: Create commit
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
|
||||
with:
|
||||
commit_message: Nightly source-language update
|
||||
file_pattern: ./src/locale/locales/en/messages.po
|
||||
- name: Push source lang to Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_sources_args: "-b main"
|
||||
|
||||
@@ -5,16 +5,15 @@ on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# Permissiosn to make comments in the pull request
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
contents: read
|
||||
# Permissions are granted per-job below; anything unlisted defaults to none
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
handle-comment:
|
||||
if: github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-deploy: ${{ steps.check-org.outputs.result }}
|
||||
|
||||
@@ -45,10 +44,10 @@ jobs:
|
||||
echo "mentioned=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check organization membership
|
||||
- name: Check commenter has write access
|
||||
if: steps.check-mention.outputs.mentioned == 'true'
|
||||
id: check-org
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
@@ -72,13 +71,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [handle-comment]
|
||||
if: needs.handle-comment.outputs.should-deploy == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Get PR HEAD SHA
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
id: pr-info
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
@@ -87,6 +89,16 @@ jobs:
|
||||
pull_number: process.env.ISSUE_NUMBER,
|
||||
});
|
||||
|
||||
// This workflow runs with repo secrets in scope, so never build
|
||||
// code from a fork: the commenter authorizes the deploy, but a
|
||||
// fork controls what code would run during it
|
||||
const expected = `${context.repo.owner}/${context.repo.repo}`;
|
||||
const head = pr.data.head.repo?.full_name;
|
||||
if (head !== expected) {
|
||||
core.setFailed(`OTA deploys are only allowed for branches in ${expected}, not forks (got ${head})`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
|
||||
console.log(`PR HEAD REF: ${pr.data.head.ref}`);
|
||||
|
||||
@@ -94,7 +106,7 @@ jobs:
|
||||
core.setOutput('head-ref', pr.data.head.ref);
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
@@ -113,14 +125,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ steps.pr-info.outputs.head-sha }}
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -141,14 +153,14 @@ jobs:
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
- name: Env
|
||||
id: env
|
||||
@@ -181,7 +193,7 @@ jobs:
|
||||
RUNTIME_VERSION:
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
@@ -198,7 +210,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@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: failure()
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
|
||||
@@ -13,25 +13,28 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
contents: read
|
||||
# Permissions are granted per-job below; anything unlisted defaults to none.
|
||||
# pull-requests: write is needed by sticky-pull-request-comment to post the
|
||||
# bundle-size and fingerprint diffs
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
webpack-analyzer:
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -68,7 +71,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Get base stats from cache
|
||||
id: get-base-stats
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: stats-base.json
|
||||
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
|
||||
@@ -89,14 +92,14 @@ jobs:
|
||||
|
||||
- name: % Get diff
|
||||
id: get-diff
|
||||
uses: NejcZdovc/bundle-size-diff@v1
|
||||
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
|
||||
with:
|
||||
base_path: "stats-base.json"
|
||||
pr_path: "../stats-new.json"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: bundle-diff
|
||||
message: |
|
||||
@@ -108,9 +111,12 @@ jobs:
|
||||
fingerprint-native:
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 100
|
||||
|
||||
@@ -118,22 +124,22 @@ jobs:
|
||||
run: git fetch origin main:main --depth 100
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
uses: bluesky-social/github-actions/fingerprint-native@main
|
||||
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
|
||||
with:
|
||||
profile: pull-request
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
@@ -151,7 +157,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@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
|
||||
@@ -4,28 +4,37 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# The push to the internal repo uses the app token below, not the GITHUB_TOKEN
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
steps:
|
||||
- name: Checkout public repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Don't persist the checkout auth header; the push below authenticates
|
||||
# with the app token embedded in the remote URL instead
|
||||
persist-credentials: false
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||
repositories: social-app-internal
|
||||
# Scope the token down from the app's full installation permissions;
|
||||
# pushing is the only thing this token is used for
|
||||
permission-contents: write
|
||||
- name: Push to internal repo
|
||||
env:
|
||||
TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email "test@users.noreply.github.com"
|
||||
git config --unset-all http.https://github.com/.extraheader
|
||||
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
|
||||
git push internal main --force
|
||||
|
||||
@@ -6,13 +6,16 @@ concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify-pnpm-lock:
|
||||
name: No manual pnpm-lock.yaml edits
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out PR HEAD
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -21,10 +24,10 @@ jobs:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git fetch origin $BASE_REF --depth=1
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Workflow security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: [".github/**"]
|
||||
pull_request:
|
||||
paths: [".github/**"]
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
# The github.token is only used by zizmor's online audits (read-only API calls)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Audit workflows with zizmor
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
with:
|
||||
# Annotate the PR directly instead of uploading SARIF to the
|
||||
# security tab, and fail the check on any finding
|
||||
advanced-security: false
|
||||
annotations: true
|
||||
# Low-confidence findings (e.g. artipacked on workflows that never
|
||||
# push) are too noisy to gate CI on
|
||||
min-confidence: medium
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Codemod to replace BskyAgent with AtpAgent
|
||||
*
|
||||
* Before:
|
||||
* import {BskyAgent} from '@atproto/api`
|
||||
* BskyAgent.appLabelers.includes(labeler)
|
||||
*
|
||||
* After:
|
||||
* import {AtpAgent} from '@atproto/api`
|
||||
* AtpAgent.appLabelers.includes(labeler)
|
||||
*
|
||||
* Handles import specifiers, type annotations, static member access
|
||||
* (BskyAgent.configure), `extends BskyAgent`, and `new BskyAgent()`. Whole
|
||||
* identifiers only, so names like `OpaqueBskyAgent` are left untouched.
|
||||
*
|
||||
* Usage: jscodeshift -t .jscodeshift/repo/bsky-agent.js <file-path>
|
||||
* Example: jscodeshift -t .jscodeshift/repo/bsky-agent.js src/lib/moderation.ts
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
export const parser = 'tsx'
|
||||
|
||||
export default function transformer(file, api) {
|
||||
const j = api.jscodeshift
|
||||
const root = j(file.source)
|
||||
|
||||
// Replace every standalone `BskyAgent` identifier with `AtpAgent`. This
|
||||
// covers imports, type references, member expressions, `extends`, and `new`.
|
||||
root
|
||||
.find(j.Identifier, {name: 'BskyAgent'})
|
||||
.replaceWith(() => j.identifier('AtpAgent'))
|
||||
|
||||
// Renaming can leave a duplicate `AtpAgent` specifier on the @atproto/api
|
||||
// import if the file already imported it. Dedupe by imported name, keeping
|
||||
// the type-only modifier only if every duplicate was type-only.
|
||||
root
|
||||
.find(j.ImportDeclaration, {source: {value: '@atproto/api'}})
|
||||
.forEach(path => {
|
||||
const seen = new Map()
|
||||
for (const spec of path.value.specifiers) {
|
||||
if (spec.type !== 'ImportSpecifier') {
|
||||
seen.set(Symbol(), spec)
|
||||
continue
|
||||
}
|
||||
const name = spec.imported.name
|
||||
const existing = seen.get(name)
|
||||
if (!existing) {
|
||||
seen.set(name, spec)
|
||||
} else if (
|
||||
existing.importKind === 'type' &&
|
||||
spec.importKind !== 'type'
|
||||
) {
|
||||
// Prefer the value (non-type) import if either usage needs it.
|
||||
seen.set(name, spec)
|
||||
}
|
||||
}
|
||||
path.value.specifiers = Array.from(seen.values())
|
||||
})
|
||||
|
||||
return root.toSource()
|
||||
}
|
||||
@@ -164,12 +164,45 @@ related code together and gives us a better visual cue that there are probably
|
||||
other files contained within this "macro" feature, whereas `Component.tsx` on
|
||||
its own looks more like a single component file.
|
||||
|
||||
### Documentation and Tests Within Features
|
||||
### Comments
|
||||
|
||||
Comment code when necessary to explain the “why” behind something; avoid
|
||||
comments that simply describe the code. Avoid Unicode characters in comments,
|
||||
e.g., use `-` not `—`.
|
||||
|
||||
Always use docblock (`/** */`) syntax for comments that document a type, type
|
||||
member, method, function, or variable. These are the comments a reader expects
|
||||
to find attached to a named declaration, and the docblock form makes that intent
|
||||
clear and surfaces nicely in editor tooltips.
|
||||
|
||||
```tsx
|
||||
type DateFieldProps = {
|
||||
/**
|
||||
* An empty string renders the placeholder and opens the picker at today (or
|
||||
* maximumDate, if earlier).
|
||||
*/
|
||||
value: string | Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-only input. Accepts a string in the format YYYY-MM-DD, or a Date object.
|
||||
*/
|
||||
export function DateField() {}
|
||||
```
|
||||
|
||||
More generally, any multiline comment should use the `/* */` block syntax rather
|
||||
than stacked `//` lines. Reserve `//` for short, single-line comments.
|
||||
|
||||
```tsx
|
||||
/*
|
||||
* The picker requires a valid date, so when value is empty we fall back to
|
||||
* maximumDate (if set) or today.
|
||||
*/
|
||||
const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
|
||||
```
|
||||
|
||||
### Documentation and Tests Within Features
|
||||
|
||||
For larger features or components, it's helpful to include a README.md file
|
||||
within the directory that explains the purpose of the feature, how it works, and
|
||||
any important implementation details. The `/Component/index.tsx` pattern lends
|
||||
|
||||
@@ -9,7 +9,7 @@ WORKDIR /app
|
||||
COPY ./bskyogcard/package.json ./
|
||||
COPY ./bskyogcard/pnpm-lock.yaml ./
|
||||
COPY ./bskyogcard/pnpm-workspace.yaml ./
|
||||
RUN npm install --global pnpm@11.1.3
|
||||
RUN npm install --global pnpm@11.5.2
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ./bskyogcard ./
|
||||
|
||||
@@ -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.5.0 && \
|
||||
npm install --global pnpm@11.9.0 && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
||||
pnpm intl:build && \
|
||||
|
||||
@@ -145,21 +145,21 @@ appId: xyz.blueskyweb.app
|
||||
id: "profileHeaderDropdownBtn"
|
||||
- tapOn: "Add to Lists"
|
||||
- assertVisible:
|
||||
id: "userAddRemoveListsModal"
|
||||
id: "userAddRemoveListsDialog"
|
||||
- tapOn:
|
||||
id: "user-bob.test-addBtn"
|
||||
- tapOn:
|
||||
id: "doneBtn"
|
||||
- assertNotVisible:
|
||||
id: "userAddRemoveListsModal"
|
||||
id: "userAddRemoveListsDialog"
|
||||
- tapOn:
|
||||
id: "profileHeaderDropdownBtn"
|
||||
- tapOn: "Add to Lists"
|
||||
- assertVisible:
|
||||
id: "userAddRemoveListsModal"
|
||||
id: "userAddRemoveListsDialog"
|
||||
- tapOn:
|
||||
id: "user-bob.test-addBtn"
|
||||
- tapOn:
|
||||
id: "doneBtn"
|
||||
- assertNotVisible:
|
||||
id: "userAddRemoveListsModal"
|
||||
id: "userAddRemoveListsDialog"
|
||||
|
||||
@@ -184,39 +184,39 @@ describe('getChatInviteCodeFromUrl', () => {
|
||||
type Case = [string, string | undefined]
|
||||
|
||||
const cases: Case[] = [
|
||||
['https://bsky.app/c/abcdefg', 'abcdefg'],
|
||||
['https://bsky.app/c/abcdefghij', 'abcdefghij'],
|
||||
['https://bsky.app/chat/abcdefg', 'abcdefg'],
|
||||
['https://bsky.app/chat/abcdefghij', 'abcdefghij'],
|
||||
// http is not recognized as a bsky.app url
|
||||
['http://bsky.app/c/abcdefg', undefined],
|
||||
['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'],
|
||||
['https://bsky.app/c/abcdefg#section', 'abcdefg'],
|
||||
['/c/abcdefg', 'abcdefg'],
|
||||
['/c/abcdefg?utm=foo', 'abcdefg'],
|
||||
['/c/abcdefg#section', 'abcdefg'],
|
||||
['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/c/abcdef', undefined],
|
||||
['/c/abcdef', undefined],
|
||||
['https://bsky.app/chat/abcdef', undefined],
|
||||
['/chat/abcdef', undefined],
|
||||
// too long
|
||||
['https://bsky.app/c/abcdefghijk', undefined],
|
||||
['/c/abcdefghijk', undefined],
|
||||
['https://bsky.app/chat/abcdefghijk', undefined],
|
||||
['/chat/abcdefghijk', undefined],
|
||||
// invalid characters
|
||||
['https://bsky.app/c/abc-def', undefined],
|
||||
['/c/abc def', undefined],
|
||||
['https://bsky.app/chat/abc-def', undefined],
|
||||
['/chat/abc def', undefined],
|
||||
// trailing path
|
||||
['https://bsky.app/c/abcdefg/extra', undefined],
|
||||
['/c/abcdefg/extra', undefined],
|
||||
['https://bsky.app/chat/abcdefg/extra', undefined],
|
||||
['/chat/abcdefg/extra', undefined],
|
||||
// wrong path
|
||||
['https://bsky.app/profile/abcdefg', undefined],
|
||||
['https://bsky.app/c', undefined],
|
||||
['https://bsky.app/chat', undefined],
|
||||
// wrong host
|
||||
['https://example.com/c/abcdefg', undefined],
|
||||
['https://example.com/chat/abcdefg', undefined],
|
||||
// not a url, not a path
|
||||
['c/abcdefg', undefined],
|
||||
['chat/abcdefg', undefined],
|
||||
['abcdefg', undefined],
|
||||
['', undefined],
|
||||
// malformed url
|
||||
['https://[invalid/c/abcdefg', undefined],
|
||||
['https://[invalid/chat/abcdefg', undefined],
|
||||
]
|
||||
|
||||
it.each(cases)('given input %p, returns %p', (input, expected) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 334 B After Width: | Height: | Size: 334 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M5 5a1 1 0 0 0-2 0v4a7 7 0 0 0 7 7h8.086l-2.293 2.293a1 1 0 0 0 1.414 1.414l2.94-2.94a2.5 2.5 0 0 0 0-3.535l-2.94-2.94a1 1 0 1 0-1.414 1.415L18.086 14H10a5 5 0 0 1-5-5V5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 273 B |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -13,7 +13,7 @@
|
||||
"format": "prettier -w src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.25",
|
||||
"@atproto/api": "0.20.11",
|
||||
"preact": "^10.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,7 +32,7 @@
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^8.0.12",
|
||||
"vite": "^8.0.16",
|
||||
"vite-bundle-analyzer": "^1.3.8"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedGallery,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
@@ -43,6 +44,11 @@ export function Embed({
|
||||
return <ImageEmbed content={content} labelInfo={labelInfo} />
|
||||
}
|
||||
|
||||
// Case 1b: Gallery (Photos v2)
|
||||
if (AppBskyEmbedGallery.isView(content)) {
|
||||
return <GalleryEmbed content={content} labelInfo={labelInfo} />
|
||||
}
|
||||
|
||||
// Case 2: External link
|
||||
if (AppBskyEmbedExternal.isView(content)) {
|
||||
return <ExternalEmbed content={content} labelInfo={labelInfo} />
|
||||
@@ -230,6 +236,8 @@ function Info({children}: {children: ComponentChildren}) {
|
||||
)
|
||||
}
|
||||
|
||||
type GridImage = {thumb: string; alt: string}
|
||||
|
||||
function ImageEmbed({
|
||||
content,
|
||||
labelInfo,
|
||||
@@ -240,20 +248,45 @@ function ImageEmbed({
|
||||
if (labelInfo) {
|
||||
return <Info>{labelInfo}</Info>
|
||||
}
|
||||
return (
|
||||
<ImageGrid
|
||||
images={content.images.map(i => ({thumb: i.thumb, alt: i.alt}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
switch (content.images.length) {
|
||||
function GalleryEmbed({
|
||||
content,
|
||||
labelInfo,
|
||||
}: {
|
||||
content: AppBskyEmbedGallery.View
|
||||
labelInfo?: string
|
||||
}) {
|
||||
if (labelInfo) {
|
||||
return <Info>{labelInfo}</Info>
|
||||
}
|
||||
const images = content.items
|
||||
.filter(AppBskyEmbedGallery.isViewImage)
|
||||
.map(i => ({thumb: i.thumbnail, alt: i.alt}))
|
||||
return <ImageGrid images={images} />
|
||||
}
|
||||
|
||||
function ImageGrid({images}: {images: GridImage[]}) {
|
||||
switch (images.length) {
|
||||
case 0:
|
||||
return null
|
||||
case 1:
|
||||
return (
|
||||
<img
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
src={images[0].thumb}
|
||||
alt={images[0].alt}
|
||||
className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]"
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
|
||||
{content.images.map((image, i) => (
|
||||
{images.map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
@@ -268,13 +301,13 @@ function ImageEmbed({
|
||||
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
|
||||
<div className="flex-1 aspect-square">
|
||||
<img
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
src={images[0].thumb}
|
||||
alt={images[0].alt}
|
||||
className="w-full h-full object-cover rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
{content.images.slice(1).map((image, i) => (
|
||||
{images.slice(1).map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
@@ -285,21 +318,36 @@ function ImageEmbed({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 4:
|
||||
default: {
|
||||
const remaining = images.length - 4
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
|
||||
{content.images.map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="aspect-[3/2] w-full object-cover rounded-sm"
|
||||
/>
|
||||
))}
|
||||
{images.slice(0, 4).map((image, i) => {
|
||||
const isOverflowCell = i === 3 && remaining > 0
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="relative aspect-[3/2] rounded-sm overflow-hidden">
|
||||
<img
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{isOverflowCell && (
|
||||
<div
|
||||
aria-label={`+${remaining} more image${remaining === 1 ? '' : 's'}, view post to see all`}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<span className="text-white text-2xl font-semibold">
|
||||
+{remaining}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,9 @@ export function Post({thread}: Props) {
|
||||
}
|
||||
|
||||
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
if (!record) return null
|
||||
// text-only check - posts with no text (e.g. gallery posts) would otherwise
|
||||
// render an empty <p> that adds an extra flex gap above the embed
|
||||
if (!record?.text) return null
|
||||
|
||||
const rt = new RichText({
|
||||
text: record.text,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.1.3",
|
||||
"version": "11.5.2",
|
||||
"onFail": "warn"
|
||||
}
|
||||
},
|
||||
@@ -18,7 +18,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.6",
|
||||
"@atproto/api": "0.20.13",
|
||||
"@atproto/common": "^0.6.1",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"express": "^4.19.2",
|
||||
|
||||
@@ -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'
|
||||
@@ -208,8 +208,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.6
|
||||
version: 0.20.6
|
||||
specifier: 0.20.13
|
||||
version: 0.20.13
|
||||
'@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.13':
|
||||
resolution: {integrity: sha512-AN7NTUsygHXnXs5Cnc6v7/BAjQydwAhE0P8hL21RpIAB4fmTksRKTU3TV//wPg4fk9ESsfNwGIAlwoybbyoZKg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -1154,7 +1154,7 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@atproto/api@0.20.6':
|
||||
'@atproto/api@0.20.13':
|
||||
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
|
||||
|
||||
@@ -4,10 +4,9 @@ import (
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
)
|
||||
|
||||
// Helpers for extracting Open Graph metadata from post embeds. These feed
|
||||
// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in
|
||||
// jsonld.go. og:video has its own path because JSON-LD does not yet emit
|
||||
// VideoObject (deferred — needs `duration` from the appview).
|
||||
// Helpers for extracting Open Graph metadata from post embeds. og:video
|
||||
// shares findVideoEmbed with the JSON-LD VideoObject path in jsonld.go so
|
||||
// the two outputs cannot drift.
|
||||
|
||||
// videoMeta holds og:video meta tag data.
|
||||
type videoMeta struct {
|
||||
@@ -21,17 +20,7 @@ type videoMeta struct {
|
||||
// extractVideoMeta returns og:video metadata, or the zero value if there's
|
||||
// no video embed. Respects embedHidden so og:video and og:image stay in sync.
|
||||
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
|
||||
if pv == nil || pv.Embed == nil || embedHidden {
|
||||
return videoMeta{}
|
||||
}
|
||||
var v *appbsky.EmbedVideo_View
|
||||
if pv.Embed.EmbedVideo_View != nil {
|
||||
v = pv.Embed.EmbedVideo_View
|
||||
} else if pv.Embed.EmbedRecordWithMedia_View != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
|
||||
v = pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
|
||||
}
|
||||
v := findVideoEmbed(pv, embedHidden)
|
||||
if v == nil || v.Playlist == "" {
|
||||
return videoMeta{}
|
||||
}
|
||||
|
||||
@@ -59,14 +59,30 @@ type discussionForumPosting struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Image []string `json:"image,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||
Video *videoObject `json:"video,omitempty"`
|
||||
DatePublished string `json:"datePublished,omitempty"`
|
||||
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
|
||||
CommentCount *int64 `json:"commentCount,omitempty"`
|
||||
Comment []comment `json:"comment,omitempty"`
|
||||
IsBasedOn string `json:"isBasedOn,omitempty"`
|
||||
IsPartOf string `json:"isPartOf,omitempty"`
|
||||
SharedContent *sharedContent `json:"sharedContent,omitempty"`
|
||||
}
|
||||
|
||||
// videoObject is the schema.org VideoObject shape for video embeds.
|
||||
// duration is omitted because the appview does not expose it.
|
||||
type videoObject struct {
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||
UploadDate string `json:"uploadDate,omitempty"`
|
||||
ContentURL string `json:"contentUrl,omitempty"`
|
||||
EmbedURL string `json:"embedUrl,omitempty"`
|
||||
Width int64 `json:"width,omitempty"`
|
||||
Height int64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
// comment is the schema.org Comment shape used in
|
||||
// DiscussionForumPosting.comment[]. The comment property does not accept
|
||||
// DiscussionForumPosting, so replies map to Comment.
|
||||
@@ -78,6 +94,7 @@ type comment struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Image []string `json:"image,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||
Video *videoObject `json:"video,omitempty"`
|
||||
DatePublished string `json:"datePublished,omitempty"`
|
||||
}
|
||||
|
||||
@@ -127,6 +144,31 @@ func bskyPostURLFromATURI(handle, atURI string) string {
|
||||
return bskyPostURL(handle, parsed.RecordKey().String())
|
||||
}
|
||||
|
||||
// bskyPostURLFromATURIWithDIDFallback returns the handle-form post URL
|
||||
// when the handle is usable, otherwise falls back to the DID-form URL
|
||||
// derived from the AT-URI's authority. Returns "" only when the AT-URI is
|
||||
// unparseable or has no record key. Used for nested fields (e.g.
|
||||
// VideoObject.embedUrl) where omitting on handle.invalid would weaken the
|
||||
// emitted structured data.
|
||||
func bskyPostURLFromATURIWithDIDFallback(handle, atURI string) string {
|
||||
parsed, err := syntax.ParseATURI(atURI)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rkey := parsed.RecordKey().String()
|
||||
if rkey == "" {
|
||||
return ""
|
||||
}
|
||||
if url := bskyPostURL(handle, rkey); url != "" {
|
||||
return url
|
||||
}
|
||||
did := parsed.Authority().String()
|
||||
if did == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", did, rkey)
|
||||
}
|
||||
|
||||
// bskyProfileURL returns the canonical handle-form profile URL, or "" if
|
||||
// the handle is unusable.
|
||||
func bskyProfileURL(handle string) string {
|
||||
@@ -136,9 +178,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 +189,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 +200,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 +222,89 @@ 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
|
||||
}
|
||||
|
||||
// findVideoEmbed returns the post's video embed view, or nil if there is
|
||||
// none or embeds are hidden. Shared with extractVideoMeta so og:video and
|
||||
// JSON-LD VideoObject stay in sync.
|
||||
func findVideoEmbed(pv *appbsky.FeedDefs_PostView, embedHidden bool) *appbsky.EmbedVideo_View {
|
||||
if pv == nil || pv.Embed == nil || embedHidden {
|
||||
return nil
|
||||
}
|
||||
if pv.Embed.EmbedVideo_View != nil {
|
||||
return pv.Embed.EmbedVideo_View
|
||||
}
|
||||
if pv.Embed.EmbedRecordWithMedia_View != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
|
||||
return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildVideoObject returns a VideoObject for the post's video embed, or
|
||||
// nil if there's no usable video. Falls back to "Video by @<handle>" when
|
||||
// alt text is empty so name is always populated (Google requires it).
|
||||
// description falls back to name for the same reason.
|
||||
func buildVideoObject(pv *appbsky.FeedDefs_PostView, embedURL, postText string, embedHidden bool) *videoObject {
|
||||
v := findVideoEmbed(pv, embedHidden)
|
||||
if v == nil || v.Playlist == "" {
|
||||
return nil
|
||||
}
|
||||
vo := &videoObject{
|
||||
Type: "VideoObject",
|
||||
ContentURL: v.Playlist,
|
||||
EmbedURL: embedURL,
|
||||
// uploadDate uses IndexedAt (not record CreatedAt) for consistency
|
||||
// with DiscussionForumPosting.datePublished on the parent post.
|
||||
UploadDate: pv.IndexedAt,
|
||||
}
|
||||
if v.Thumbnail != nil {
|
||||
vo.ThumbnailURL = *v.Thumbnail
|
||||
}
|
||||
switch {
|
||||
case v.Alt != nil && *v.Alt != "":
|
||||
vo.Name = *v.Alt
|
||||
case pv.Author != nil && pv.Author.Handle != "" && pv.Author.Handle != "handle.invalid":
|
||||
vo.Name = "Video by @" + pv.Author.Handle
|
||||
default:
|
||||
vo.Name = "Video on Bluesky"
|
||||
}
|
||||
if postText != "" {
|
||||
vo.Description = postText
|
||||
} else {
|
||||
vo.Description = vo.Name
|
||||
}
|
||||
if v.AspectRatio != nil {
|
||||
vo.Width = v.AspectRatio.Width
|
||||
vo.Height = v.AspectRatio.Height
|
||||
}
|
||||
return vo
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -214,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// threadRootURI returns the AT-URI of the root post of the thread a reply
|
||||
// belongs to, or "" if the post is not a reply or the record is malformed.
|
||||
func threadRootURI(pv *appbsky.FeedDefs_PostView) string {
|
||||
if pv == nil || pv.Record == nil {
|
||||
return ""
|
||||
}
|
||||
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
|
||||
if !ok || rec.Reply == nil || rec.Reply.Root == nil {
|
||||
return ""
|
||||
}
|
||||
return rec.Reply.Root.Uri
|
||||
}
|
||||
|
||||
// findRootPostInParents walks tv's parent chain upward and returns the
|
||||
// PostView whose URI matches rootURI, or nil if the root is not present.
|
||||
// The root is absent when the chain is truncated by parentHeight (reply
|
||||
// deeper than the fetched height) or broken by a blocked/not-found parent.
|
||||
// Avoids a separate FeedGetPosts call when the thread response already
|
||||
// contains the root.
|
||||
func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView {
|
||||
if rootURI == "" {
|
||||
return nil
|
||||
}
|
||||
for node := tv; node != nil; {
|
||||
if node.Post != nil && node.Post.Uri == rootURI {
|
||||
return node.Post
|
||||
}
|
||||
if node.Parent == nil {
|
||||
return nil
|
||||
}
|
||||
// Only threadViewPost parents continue the chain; a blocked or
|
||||
// not-found parent breaks it before reaching the root.
|
||||
node = node.Parent.FeedDefs_ThreadViewPost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAuthor constructs a Person. Organization classification for
|
||||
// custom-domain accounts is a future enhancement.
|
||||
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
|
||||
@@ -363,14 +531,18 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
|
||||
thumb = images[0]
|
||||
}
|
||||
|
||||
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
|
||||
postText := postRecordText(pv)
|
||||
|
||||
node := discussionForumPosting{
|
||||
Type: "DiscussionForumPosting",
|
||||
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
|
||||
URL: postURL,
|
||||
Identifier: pv.Uri,
|
||||
Author: buildAuthor(pv.Author),
|
||||
Text: postRecordText(pv),
|
||||
Text: postText,
|
||||
Image: images,
|
||||
ThumbnailURL: thumb,
|
||||
Video: buildVideoObject(pv, postURL, postText, embedHidden),
|
||||
DatePublished: pv.IndexedAt,
|
||||
InteractionStat: buildPostStats(pv),
|
||||
}
|
||||
@@ -433,27 +605,38 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
|
||||
if len(images) > 0 {
|
||||
thumb = images[0]
|
||||
}
|
||||
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
|
||||
postText := postRecordText(pv)
|
||||
return comment{
|
||||
Type: "Comment",
|
||||
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
|
||||
URL: postURL,
|
||||
Identifier: pv.Uri,
|
||||
Author: buildAuthor(pv.Author),
|
||||
Text: postRecordText(pv),
|
||||
Text: postText,
|
||||
Image: images,
|
||||
ThumbnailURL: thumb,
|
||||
Video: buildVideoObject(pv, postURL, postText, embedHidden),
|
||||
DatePublished: pv.IndexedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// buildPostJSONLD marshals the WebPage envelope wrapping a
|
||||
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
|
||||
// (as a fallback) mainEntity.url so they always agree.
|
||||
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
|
||||
// (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
|
||||
// non-empty, is the handle-form canonical URL of the thread root the handler
|
||||
// resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could
|
||||
// not be resolved). We never emit a DID-form isPartOf because it would not
|
||||
// match the root page's handle-form canonical.
|
||||
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
|
||||
if pv == nil || pv.Author == nil {
|
||||
return "", fmt.Errorf("nil post view or author")
|
||||
}
|
||||
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
|
||||
|
||||
if isPartOfURL != "" {
|
||||
node.IsPartOf = isPartOfURL
|
||||
}
|
||||
|
||||
// mainEntity.url is empty when the author handle is unusable; fall back
|
||||
// to canonicalURL so it agrees with envelope.url.
|
||||
if node.URL == "" {
|
||||
|
||||
@@ -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) {
|
||||
@@ -129,6 +183,19 @@ func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) {
|
||||
}
|
||||
}
|
||||
|
||||
// withReplyRoot marks the post as a reply by setting its record's Reply.Root
|
||||
// strong-ref to the given thread-root post.
|
||||
func withReplyRoot(rootDid, rootRkey string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
rec, _ := pv.Record.Val.(*appbsky.FeedPost)
|
||||
uri := "at://" + rootDid + "/app.bsky.feed.post/" + rootRkey
|
||||
rec.Reply = &appbsky.FeedPost_ReplyRef{
|
||||
Root: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
|
||||
Parent: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withSelfLabel adds a self-label that should hide embeds.
|
||||
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
@@ -209,7 +276,7 @@ func unmarshalLD(t *testing.T, s string) map[string]any {
|
||||
func TestBuildPostJSONLD_Bare(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -279,7 +346,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -299,10 +366,92 @@ 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))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["thumbnailUrl"] != thumb {
|
||||
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
|
||||
@@ -315,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
||||
|
||||
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
|
||||
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
|
||||
@@ -324,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
|
||||
|
||||
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["isBasedOn"]; present {
|
||||
t.Errorf("blocked quote should not produce isBasedOn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_IsPartOf(t *testing.T) {
|
||||
// isPartOf is sourced solely from the handler-resolved URL. When supplied,
|
||||
// it is emitted on the main post; when empty, no isPartOf is present.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply")
|
||||
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["isPartOf"] != isPartOf {
|
||||
t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf)
|
||||
}
|
||||
|
||||
out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main = unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["isPartOf"]; present {
|
||||
t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadRootURI(t *testing.T) {
|
||||
// A reply returns its root AT-URI; a non-reply returns "".
|
||||
reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply",
|
||||
withReplyRoot("did:plc:root", "rootrkey"))
|
||||
want := "at://did:plc:root/app.bsky.feed.post/rootrkey"
|
||||
if got := threadRootURI(reply); got != want {
|
||||
t.Errorf("threadRootURI = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply")
|
||||
if got := threadRootURI(post); got != "" {
|
||||
t.Errorf("threadRootURI on non-reply = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRootPostInParents(t *testing.T) {
|
||||
rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root")
|
||||
rootURI := rootPost.Uri
|
||||
|
||||
// tvp wraps a PostView, optionally chaining to a parent thread node.
|
||||
tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost {
|
||||
node := &appbsky.FeedDefs_ThreadViewPost{Post: pv}
|
||||
if parent != nil {
|
||||
node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent}
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
t.Run("direct reply, parent is root", func(t *testing.T) {
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
|
||||
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
|
||||
t.Errorf("expected root post, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-level chain", func(t *testing.T) {
|
||||
mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil))
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid)
|
||||
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
|
||||
t.Errorf("expected root post in chain, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("root absent, chain truncated", func(t *testing.T) {
|
||||
// Topmost parent is not the root (e.g. parentHeight cut off the chain).
|
||||
topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid")
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil))
|
||||
if got := findRootPostInParents(leaf, rootURI); got != nil {
|
||||
t.Errorf("expected nil when root absent, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chain broken by blocked parent", func(t *testing.T) {
|
||||
// A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost,
|
||||
// breaking the walk before the root.
|
||||
leaf := &appbsky.FeedDefs_ThreadViewPost{
|
||||
Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"),
|
||||
Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{
|
||||
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI},
|
||||
},
|
||||
}
|
||||
if got := findRootPostInParents(leaf, rootURI); got != nil {
|
||||
t.Errorf("expected nil when chain broken by blocked parent, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty rootURI", func(t *testing.T) {
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
|
||||
if got := findRootPostInParents(leaf, ""); got != nil {
|
||||
t.Errorf("expected nil for empty rootURI, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) {
|
||||
// Replies surfaced under the main post as comment[] are Comment nodes and
|
||||
// never carry isPartOf, even when the main post has one.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply")
|
||||
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
if _, present := c["isPartOf"]; present {
|
||||
t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
sc, ok := main["sharedContent"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -351,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/x@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||
withImages(thumb), withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["image"]; present {
|
||||
t.Errorf("hidden-embed post should not emit image")
|
||||
@@ -361,11 +616,30 @@ 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> 🎉"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -401,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
|
||||
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
|
||||
})
|
||||
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
|
||||
if cc := main["commentCount"].(float64); int64(cc) != 14 {
|
||||
@@ -443,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
|
||||
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
|
||||
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
|
||||
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
||||
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels)
|
||||
envelope := unmarshalLD(t, out)
|
||||
main := envelope["mainEntity"].(map[string]any)
|
||||
// mainEntity.url falls back to the caller's canonical URL so envelope
|
||||
@@ -492,7 +766,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
|
||||
out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
env := unmarshalLD(t, out)
|
||||
main := env["mainEntity"].(map[string]any)
|
||||
if env["url"] != tc.canonical {
|
||||
@@ -509,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
|
||||
// Defensive: don't panic if Author is nil.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
pv.Author = nil
|
||||
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
t.Errorf("expected error for nil-author post, got nil")
|
||||
}
|
||||
}
|
||||
@@ -527,7 +801,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
|
||||
}
|
||||
out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -548,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
|
||||
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
|
||||
}
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
imgs, ok := c["image"].([]any)
|
||||
@@ -755,7 +1029,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
|
||||
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
|
||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
|
||||
withPostLabel("!hide", false))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != good.Uri {
|
||||
t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
|
||||
@@ -768,7 +1042,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
|
||||
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
|
||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
|
||||
withSelfLabel("spam"))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != good.Uri {
|
||||
t.Errorf("expected self-labeled reply dropped, got %v", ids)
|
||||
@@ -784,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
|
||||
// union behavior.
|
||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
|
||||
withPostLabel("self-harm", false))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != good.Uri {
|
||||
t.Errorf("expected embed-labeled reply dropped, got %v", ids)
|
||||
@@ -796,7 +1070,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
|
||||
withPostLabel("!hide", true))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != reply.Uri {
|
||||
t.Errorf("expected negated-label reply to be kept, got %v", ids)
|
||||
@@ -807,7 +1081,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
|
||||
// Reply author should also carry a DID identifier.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
auth, ok := c["author"].(map[string]any)
|
||||
@@ -948,7 +1222,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
|
||||
})
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
|
||||
withVerifications(state))
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -988,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
|
||||
withVerifications(state))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
auth := c["author"].(map[string]any)
|
||||
@@ -1049,3 +1323,339 @@ func TestBuildProfileJSONLD_HasPartAuthorReviewedBy(t *testing.T) {
|
||||
t.Errorf("verifier identifier wrong: %v", rb[0])
|
||||
}
|
||||
}
|
||||
|
||||
// videoEmbedOpts configures withVideoFull. Zero values mean "not set".
|
||||
type videoEmbedOpts struct {
|
||||
thumbnail string
|
||||
playlist string
|
||||
alt string
|
||||
width int64
|
||||
height int64
|
||||
hasAspect bool
|
||||
recordMedia bool // nest under EmbedRecordWithMedia_View.Media
|
||||
}
|
||||
|
||||
// withVideoFull installs a fully-specified video embed for VideoObject tests.
|
||||
func withVideoFull(o videoEmbedOpts) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
v := &appbsky.EmbedVideo_View{Playlist: o.playlist}
|
||||
if o.thumbnail != "" {
|
||||
v.Thumbnail = strPtr(o.thumbnail)
|
||||
}
|
||||
if o.alt != "" {
|
||||
v.Alt = strPtr(o.alt)
|
||||
}
|
||||
if o.hasAspect {
|
||||
v.AspectRatio = &appbsky.EmbedDefs_AspectRatio{Width: o.width, Height: o.height}
|
||||
}
|
||||
if o.recordMedia {
|
||||
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://did:plc:quoted/app.bsky.feed.post/q",
|
||||
Cid: "bafy-quoted",
|
||||
Author: &appbsky.ActorDefs_ProfileViewBasic{
|
||||
Did: "did:plc:quoted",
|
||||
Handle: "quoted.bsky.social",
|
||||
},
|
||||
IndexedAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
Media: &appbsky.EmbedRecordWithMedia_View_Media{
|
||||
EmbedVideo_View: v,
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedVideo_View: v,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
playlist := "https://video.bsky.app/v/did:plc:alice/v/playlist.m3u8"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch this",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
thumbnail: thumb, playlist: playlist, alt: "A trip to the park",
|
||||
hasAspect: true, width: 16, height: 9,
|
||||
}))
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("video missing from mainEntity")
|
||||
}
|
||||
if video["@type"] != "VideoObject" {
|
||||
t.Errorf("@type = %v, want VideoObject", video["@type"])
|
||||
}
|
||||
if video["name"] != "A trip to the park" {
|
||||
t.Errorf("name = %v, want alt text", video["name"])
|
||||
}
|
||||
if video["description"] != "watch this" {
|
||||
t.Errorf("description = %v, want post text", video["description"])
|
||||
}
|
||||
if video["thumbnailUrl"] != thumb {
|
||||
t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb)
|
||||
}
|
||||
if video["uploadDate"] != pv.IndexedAt {
|
||||
t.Errorf("uploadDate = %v, want %v", video["uploadDate"], pv.IndexedAt)
|
||||
}
|
||||
if video["contentUrl"] != playlist {
|
||||
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
|
||||
}
|
||||
if video["embedUrl"] != canonical {
|
||||
t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical)
|
||||
}
|
||||
if w, _ := video["width"].(float64); int64(w) != 16 {
|
||||
t.Errorf("width = %v, want 16", video["width"])
|
||||
}
|
||||
if h, _ := video["height"].(float64); int64(h) != 9 {
|
||||
t.Errorf("height = %v, want 9", video["height"])
|
||||
}
|
||||
// post thumbnailUrl should equal the video thumb (byte-equal og:image).
|
||||
if main["thumbnailUrl"] != thumb {
|
||||
t.Errorf("post thumbnailUrl = %v, want %v", main["thumbnailUrl"], thumb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
|
||||
// No alt text -> "Video by @<handle>".
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no alt here",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("video missing")
|
||||
}
|
||||
if video["name"] != "Video by @alice.bsky.social" {
|
||||
t.Errorf("name fallback = %v", video["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
|
||||
// handle.invalid + no alt -> generic fallback.
|
||||
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "x",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video := main["video"].(map[string]any)
|
||||
if video["name"] != "Video on Bluesky" {
|
||||
t.Errorf("name fallback = %v, want generic", video["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
|
||||
// Empty post text -> description falls back to name so Google's video
|
||||
// rich-result requirement (description present) is satisfied.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video := main["video"].(map[string]any)
|
||||
if video["description"] != "scenic clip" {
|
||||
t.Errorf("description fallback = %v, want name", video["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video := main["video"].(map[string]any)
|
||||
if _, present := video["width"]; present {
|
||||
t.Errorf("width should be omitted when AspectRatio missing")
|
||||
}
|
||||
if _, present := video["height"]; present {
|
||||
t.Errorf("height should be omitted when AspectRatio missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
|
||||
// No playlist -> VideoObject suppressed; image[]/thumbnailUrl still set
|
||||
// from the video thumb (existing behavior).
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
|
||||
withVideo(thumb))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
t.Errorf("video without playlist should not produce VideoObject")
|
||||
}
|
||||
if main["thumbnailUrl"] != thumb {
|
||||
t.Errorf("thumbnailUrl should still be set from video thumb, got %v", main["thumbnailUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
|
||||
// Self-labeled hide drops the video, parallel to the image-hide test.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
thumbnail: "https://cdn.bsky.app/img/x@jpeg",
|
||||
playlist: "https://video.bsky.app/p.m3u8",
|
||||
alt: "should be dropped",
|
||||
}),
|
||||
withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
t.Errorf("hidden-embed post should not emit video")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
playlist := "https://video.bsky.app/p.m3u8"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+video",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
|
||||
hasAspect: true, width: 4, height: 3,
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("video missing on record-with-media post")
|
||||
}
|
||||
if video["contentUrl"] != playlist {
|
||||
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
|
||||
}
|
||||
if video["thumbnailUrl"] != thumb {
|
||||
t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb)
|
||||
}
|
||||
// quote-post still surfaces via isBasedOn alongside the video.
|
||||
if main["isBasedOn"] != "https://bsky.app/profile/quoted.bsky.social/post/q" {
|
||||
t.Errorf("isBasedOn = %v", main["isBasedOn"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
*pv.ReplyCount = 1
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:bob/v@jpeg"
|
||||
playlist := "https://video.bsky.app/bob.m3u8"
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "watch",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
video, ok := c["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("reply video missing")
|
||||
}
|
||||
if video["@type"] != "VideoObject" {
|
||||
t.Errorf("reply video @type = %v", video["@type"])
|
||||
}
|
||||
if video["name"] != "bob's clip" {
|
||||
t.Errorf("reply video name = %v", video["name"])
|
||||
}
|
||||
if video["contentUrl"] != playlist {
|
||||
t.Errorf("reply video contentUrl = %v", video["contentUrl"])
|
||||
}
|
||||
if video["embedUrl"] != "https://bsky.app/profile/bob.bsky.social/post/rep1" {
|
||||
t.Errorf("reply video embedUrl = %v", video["embedUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
t.Errorf("post without video should not include video field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileJSONLD_HasPartVideo(t *testing.T) {
|
||||
pv := newProfileViewDetailed()
|
||||
playlist := "https://video.bsky.app/p.m3u8"
|
||||
post := makePostView("alice.bsky.social", "did:plc:alice", "rp1", "see",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: playlist, alt: "alt",
|
||||
}))
|
||||
out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{post}, hideEmbedLabels, hideReplyLabels)
|
||||
page := unmarshalLD(t, out)
|
||||
hp := page["hasPart"].([]any)
|
||||
if len(hp) != 1 {
|
||||
t.Fatalf("expected 1 hasPart entry, got %d", len(hp))
|
||||
}
|
||||
video, ok := hp[0].(map[string]any)["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("hasPart entry should carry video, got %v", hp[0])
|
||||
}
|
||||
if video["contentUrl"] != playlist {
|
||||
t.Errorf("hasPart video contentUrl = %v", video["contentUrl"])
|
||||
}
|
||||
if video["embedUrl"] != "https://bsky.app/profile/alice.bsky.social/post/rp1" {
|
||||
t.Errorf("hasPart video embedUrl = %v", video["embedUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
// handle.invalid authors must still produce a non-empty embedUrl on the
|
||||
// VideoObject. The handle-form URL is unusable, so we fall back to the
|
||||
// DID-form URL derived from the AT-URI authority. Without this fallback,
|
||||
// omitempty drops embedUrl and Google's video indexer loses the canonical
|
||||
// page reference.
|
||||
func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
|
||||
playlist := "https://video.bsky.app/p.m3u8"
|
||||
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "watch",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: playlist, alt: "scenic clip",
|
||||
}))
|
||||
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
||||
out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("video missing on handle.invalid post")
|
||||
}
|
||||
if video["embedUrl"] != canonical {
|
||||
t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical)
|
||||
}
|
||||
if video["contentUrl"] != playlist {
|
||||
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
|
||||
}
|
||||
}
|
||||
|
||||
// Same fallback applies to videos on replies whose author is handle.invalid.
|
||||
func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
*pv.ReplyCount = 1
|
||||
playlist := "https://video.bsky.app/bob.m3u8"
|
||||
reply := makePostView("handle.invalid", "did:plc:bob", "rep1", "watch",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: playlist, alt: "bob's clip",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
video, ok := c["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("reply video missing")
|
||||
}
|
||||
want := "https://bsky.app/profile/did:plc:bob/post/rep1"
|
||||
if video["embedUrl"] != want {
|
||||
t.Errorf("reply video embedUrl = %v, want %v", video["embedUrl"], want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// postAuthorRequiresAuth reports whether the post author self-applied the
|
||||
// !no-unauthenticated label, read from the author view embedded in a
|
||||
// getPostThread response. The appview surfaces the account's profile-record
|
||||
// self-labels on the post author (src == author DID), so this mirrors
|
||||
// profileRequiresAuth without a separate ActorGetProfile call.
|
||||
func postAuthorRequiresAuth(pv *appbsky.FeedDefs_PostView) bool {
|
||||
if pv == nil || pv.Author == nil {
|
||||
return false
|
||||
}
|
||||
for _, label := range pv.Author.Labels {
|
||||
if label.Src == pv.Author.Did && label.Val == "!no-unauthenticated" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -77,3 +77,76 @@ func TestProfileRequiresAuth(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAuthorRequiresAuth(t *testing.T) {
|
||||
negTrue := true
|
||||
|
||||
authorPV := func(labels []*comatprototypes.LabelDefs_Label) *appbsky.FeedDefs_PostView {
|
||||
return &appbsky.FeedDefs_PostView{
|
||||
Author: &appbsky.ActorDefs_ProfileViewBasic{
|
||||
Did: "did:plc:alice",
|
||||
Handle: "alice.bsky.social",
|
||||
Labels: labels,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pv *appbsky.FeedDefs_PostView
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil post view",
|
||||
pv: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil author",
|
||||
pv: &appbsky.FeedDefs_PostView{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no labels",
|
||||
pv: authorPV(nil),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "self-applied !no-unauthenticated",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:alice", Val: "!no-unauthenticated"},
|
||||
}),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "label from a different src does not gate",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:labeler", Val: "!no-unauthenticated"},
|
||||
}),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "different label value does not gate",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:alice", Val: "spam"},
|
||||
}),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Negation isn't honored - matches profileRequiresAuth behavior.
|
||||
name: "negated label still triggers (matches profile behavior)",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:alice", Val: "!no-unauthenticated", Neg: &negTrue},
|
||||
}),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := postAuthorRequiresAuth(tt.pv); got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,13 @@ func run(args []string) {
|
||||
Value: ":8100",
|
||||
EnvVars: []string{"HTTP_ADDRESS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "metrics-address",
|
||||
Usage: "Specify the local IP/port to bind the metrics server to",
|
||||
Required: false,
|
||||
Value: ":9090",
|
||||
EnvVars: []string{"METRICS_HTTP_ADDRESS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "link-host",
|
||||
Usage: "scheme, hostname, and port of link service",
|
||||
|
||||
@@ -43,9 +43,16 @@ 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)
|
||||
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -74,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
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",
|
||||
@@ -98,10 +105,44 @@ 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")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
|
||||
@@ -167,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
|
||||
// og:url and <link rel="canonical"> must emit the same URL.
|
||||
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
@@ -192,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
||||
// video without a thumbnail dropped og:video entirely.
|
||||
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
videoURL := "https://video.bsky.app/v.m3u8"
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
@@ -35,15 +36,17 @@ import (
|
||||
"github.com/labstack/echo-contrib/echoprometheus"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
chatXrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
metricsHttpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
chatXrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
|
||||
ipccClient http.Client
|
||||
|
||||
@@ -67,6 +70,7 @@ type Config struct {
|
||||
func serve(cctx *cli.Context) error {
|
||||
debug := cctx.Bool("debug")
|
||||
httpAddress := cctx.String("http-address")
|
||||
metricsAddress := cctx.String("metrics-address")
|
||||
appviewHost := cctx.String("appview-host")
|
||||
chatHost := cctx.String("chat-host")
|
||||
ogcardHost := cctx.String("ogcard-host")
|
||||
@@ -88,7 +92,7 @@ func serve(cctx *cli.Context) error {
|
||||
Host: appviewHost,
|
||||
}
|
||||
|
||||
// optional client for the chat appview, used by /c/<code> for OG previews.
|
||||
// optional client for the chat appview, used by /chat/<code> for OG previews.
|
||||
var chatXrpcc *xrpc.Client
|
||||
if chatHost != "" {
|
||||
chatXrpcc = &xrpc.Client{
|
||||
@@ -296,50 +300,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("/messages/:conversation/requests", 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)
|
||||
@@ -363,14 +367,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("/c/:code", server.WebChatInvite)
|
||||
e.GET("/chat/:code", server.WebChatInvite)
|
||||
|
||||
// bookmarks
|
||||
e.GET("/saved", server.WebGeneric)
|
||||
e.GET("/saved", server.WebGenericNoindex)
|
||||
|
||||
// ipcc
|
||||
e.GET("/ipcc", server.WebIpCC)
|
||||
@@ -387,6 +391,19 @@ func serve(cctx *cli.Context) error {
|
||||
e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl))
|
||||
}
|
||||
|
||||
metricsHttpd, metricsListener, err := newMetricsHTTPServer(metricsAddress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server.metricsHttpd = metricsHttpd
|
||||
|
||||
log.Infof("starting metrics server address=%s", metricsAddress)
|
||||
go func() {
|
||||
if err := metricsHttpd.Serve(metricsListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Errorf("metrics HTTP server shutting down unexpectedly: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start the server.
|
||||
log.Infof("starting server address=%s", httpAddress)
|
||||
go func() {
|
||||
@@ -419,6 +436,24 @@ func serve(cctx *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newMetricsHTTPServer(address string) (*http.Server, net.Listener, error) {
|
||||
metricsMux := http.NewServeMux()
|
||||
metricsMux.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
metricsHttpd := &http.Server{
|
||||
Addr: address,
|
||||
Handler: metricsMux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
metricsListener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("listen metrics address %s: %w", address, err)
|
||||
}
|
||||
|
||||
return metricsHttpd, metricsListener, nil
|
||||
}
|
||||
|
||||
func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
srv.echo.ServeHTTP(rw, req)
|
||||
}
|
||||
@@ -429,7 +464,18 @@ func (srv *Server) Shutdown() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return srv.httpd.Shutdown(ctx)
|
||||
var shutdownErr error
|
||||
if srv.metricsHttpd != nil {
|
||||
if err := srv.metricsHttpd.Shutdown(ctx); err != nil {
|
||||
shutdownErr = fmt.Errorf("metrics HTTP server shutdown error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := srv.httpd.Shutdown(ctx); err != nil {
|
||||
return errors.Join(shutdownErr, err)
|
||||
}
|
||||
|
||||
return shutdownErr
|
||||
}
|
||||
|
||||
// NewTemplateContext returns a new pongo2 context with some default values.
|
||||
@@ -437,6 +483,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,10 +537,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 {
|
||||
@@ -544,49 +620,54 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
|
||||
identifier := handleOrDID.Normalize().String()
|
||||
|
||||
// requires two fetches: first fetch profile (!)
|
||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
unauthedViewingOkay := !profileRequiresAuth(pv)
|
||||
|
||||
req := c.Request()
|
||||
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
|
||||
// Always prefer the handle-form URL so JSON-LD `url` and
|
||||
// <link rel="canonical"> match. Falls back to requestURI when the
|
||||
// handle is unusable (template strips query/fragment).
|
||||
canonicalURL := bskyPostURL(pv.Handle, rkey.String())
|
||||
|
||||
if !unauthedViewingOkay {
|
||||
// Provide minimal OpenGraph data for auth-required posts
|
||||
data["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
data["canonicalURL"] = canonicalURL
|
||||
}
|
||||
data["requiresAuth"] = true
|
||||
data["profileHandle"] = pv.Handle
|
||||
if pv.DisplayName != nil {
|
||||
data["profileDisplayName"] = *pv.DisplayName
|
||||
}
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
// then fetch the post thread (with extra context)
|
||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", pv.Did, rkey)
|
||||
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
|
||||
// Fetch the post thread directly. The AT-URI authority accepts either a
|
||||
// handle or a DID (the appview resolves it), so we skip the separate
|
||||
// ActorGetProfile call and source identity, the canonical URL, and the
|
||||
// auth gate from the thread response's author view instead.
|
||||
// parentHeight=80 (the lexicon default) pulls the reply's ancestor chain
|
||||
// up to the root in nearly all threads, letting isPartOf resolve from this
|
||||
// response without a separate FeedGetPosts call.
|
||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", identifier, rkey)
|
||||
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 80, uri)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch post: %s\t%v", uri, err)
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
threadView := tpv.Thread.FeedDefs_ThreadViewPost
|
||||
if threadView == nil || threadView.Post == nil {
|
||||
if threadView == nil || threadView.Post == nil || threadView.Post.Author == nil {
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
postView := threadView.Post
|
||||
|
||||
// Always prefer the handle-form URL so JSON-LD `url` and
|
||||
// <link rel="canonical"> match. Falls back to requestURI when the
|
||||
// handle is unusable (template strips query/fragment).
|
||||
canonicalURL := bskyPostURL(postView.Author.Handle, rkey.String())
|
||||
|
||||
// Gate before populating any post content into the template so that
|
||||
// !no-unauthenticated posts never leak text/media. The appview returns the
|
||||
// post (with the author self-label) to unauthed callers, so we detect the
|
||||
// label here rather than via a profile fetch.
|
||||
if postAuthorRequiresAuth(postView) {
|
||||
// Provide minimal OpenGraph data for auth-required posts
|
||||
data["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
data["canonicalURL"] = canonicalURL
|
||||
}
|
||||
data["requiresAuth"] = true
|
||||
data["noindex"] = true
|
||||
data["nofollow"] = true
|
||||
data["profileHandle"] = postView.Author.Handle
|
||||
if postView.Author.DisplayName != nil {
|
||||
data["profileDisplayName"] = *postView.Author.DisplayName
|
||||
}
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
data["postView"] = postView
|
||||
data["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
@@ -616,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
if jsonldURL == "" {
|
||||
jsonldURL = requestURI
|
||||
}
|
||||
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
|
||||
// Best-effort: resolve a reply's thread root to its handle-form canonical
|
||||
// URL for isPartOf. Prefer the root already present in the thread response
|
||||
// (parentHeight=80); fall back to a bounded FeedGetPosts only when the
|
||||
// chain is truncated (very deep thread) or broken by a blocked/not-found
|
||||
// ancestor. On timeout, error, or an unresolvable root we omit isPartOf
|
||||
// rather than point at a non-indexable page.
|
||||
isPartOfURL := ""
|
||||
if rootURI := threadRootURI(postView); rootURI != "" {
|
||||
if rootPost := findRootPostInParents(threadView, rootURI); rootPost != nil && rootPost.Author != nil {
|
||||
isPartOfURL = bskyPostURLFromATURI(rootPost.Author.Handle, rootURI)
|
||||
} else {
|
||||
pctx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
||||
if posts, perr := appbsky.FeedGetPosts(pctx, srv.xrpcc, []string{rootURI}); perr != nil {
|
||||
log.Warnf("failed to resolve thread root post for isPartOf: %s\t%v", rootURI, perr)
|
||||
} else if len(posts.Posts) > 0 && posts.Posts[0].Author != nil {
|
||||
// Handle-form only (no DID fallback): isPartOf must match the
|
||||
// root page's handle-form canonical, so an unusable handle omits
|
||||
// isPartOf rather than point at a non-canonical DID-form URL.
|
||||
isPartOfURL = bskyPostURLFromATURI(posts.Posts[0].Author.Handle, rootURI)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, isPartOfURL, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
data["postJSONLD"] = jsonld
|
||||
} else {
|
||||
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
|
||||
@@ -673,6 +779,7 @@ 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")
|
||||
@@ -695,7 +802,7 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
|
||||
|
||||
data["title"] = preview.Name
|
||||
if srv.cfg.ogcardHost != "" {
|
||||
// bskyogcard registers this route as /chat-invite/:code, not /c/:code.
|
||||
// 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)
|
||||
@@ -760,6 +867,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 {
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0
|
||||
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,8 +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-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/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" />
|
||||
|
||||
@@ -57,6 +57,9 @@ release or OTA.
|
||||
Log in to the EAS CLI with `eas login` and manually set the build numbers to the
|
||||
values you found in **Step 1**.
|
||||
|
||||
> [!NOTE]
|
||||
> If you don’t already have the EAS CLI, you can install it with `pnpm add -g eas-cli`.
|
||||
|
||||
```sh
|
||||
> npx eas build:version:set -p ios
|
||||
>
|
||||
@@ -114,7 +117,7 @@ To confirm this, as mentioned above, you must completely clear the TestFlight
|
||||
build from your device and re-install from the App Store. Then, you'll need to:
|
||||
- Launch the app (or quit and reopen) and wait ~15s for the download to complete
|
||||
- Quit and reopen the app
|
||||
- Check the `Settings > About` page and confirm the has matches the most recent has on your OTA branch
|
||||
- Check the `Settings > About` page and confirm the hash matches the most recent hash on your OTA branch
|
||||
|
||||
### 9. Reset build numbers
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ values.
|
||||
Install dependencies in `/dev-env`
|
||||
|
||||
```
|
||||
cd dev-env && yarn
|
||||
cd dev-env && pnpm i
|
||||
```
|
||||
|
||||
## Using Maestro
|
||||
@@ -27,7 +27,7 @@ adb reverse tcp:3000 tcp:3000
|
||||
|
||||
- In one tab, run `pnpm e2e:mock-server`
|
||||
- In a second tab, run `pnpm e2e:build`
|
||||
- In a third tab, run `pnpm e2e:run`
|
||||
- In a third tab, run `pnpm e2e:run __e2e__`
|
||||
|
||||
## Using Flashlight for Performance Testing
|
||||
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
|
||||
|
||||
@@ -47,21 +47,6 @@
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"autoIncrement": false,
|
||||
"buildArtifactPaths": ["ios/build/*"]
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": false
|
||||
},
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"testflight": {
|
||||
"extends": "base",
|
||||
"ios": {
|
||||
@@ -78,7 +63,6 @@
|
||||
},
|
||||
"testflight-android": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"autoIncrement": true,
|
||||
"buildArtifactPaths": ["ios/build/*"]
|
||||
@@ -96,6 +80,14 @@
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6444370199"
|
||||
},
|
||||
"android": {
|
||||
"track": "production"
|
||||
}
|
||||
},
|
||||
"testflight-android": {
|
||||
"android": {
|
||||
"track": "internal"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,113 @@
|
||||
{
|
||||
"modules/bottom-sheet/src/BottomSheetNativeComponent.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"modules/bottom-sheet/src/BottomSheetPortal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/bottom-sheet/src/lib/Portal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts": {
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-gif-view/src/GifView.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 4
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 4
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-gif-view/src/GifView.web.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts": {
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts": {
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/SharedPrefs/index.native.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 9
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx": {
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx": {
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-emoji-picker/src/EmojiPickerView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/Navigation.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -19,11 +128,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/ageAssurance/util.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/alf/util/flatten.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -513,11 +617,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/InAppBrowserConsent.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/LanguageSelectDialog.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -525,7 +624,7 @@
|
||||
},
|
||||
"src/components/dialogs/LinkWarning.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/MutedWords.tsx": {
|
||||
@@ -603,11 +702,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dms/MessageItem.tsx": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/forms/DateField/index.web.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -650,14 +744,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/hooks/useFullscreen.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/hooks/useLandingEntry.native.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
@@ -757,11 +843,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/features/liveNow/components/LiveStatusDialog.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/features/liveNow/index.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
@@ -865,14 +946,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/hooks/useNotificationHandler.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 16
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/hooks/useOTAUpdates.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -884,14 +957,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/lib/hooks/useOpenLink.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/hooks/usePermissions.ts": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
@@ -1763,11 +1828,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/queries/list-memberships.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/state/queries/list.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 7
|
||||
@@ -1903,12 +1963,6 @@
|
||||
"src/state/session/agent.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/shell/color-mode.tsx": {
|
||||
@@ -2096,20 +2150,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/lists/ListMembers.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/lists/MyLists.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -2135,17 +2175,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/modals/Modal.tsx": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/refs": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/notifications/NotificationFeed.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -2271,23 +2300,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/profile/ProfileMenu.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 6
|
||||
},
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 4
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 6
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 12
|
||||
}
|
||||
},
|
||||
"src/view/com/testing/TestCtrls.e2e.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -2326,9 +2338,6 @@
|
||||
"src/view/com/util/Link.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/util/LoadingPlaceholder.tsx": {
|
||||
@@ -2424,11 +2433,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/screens/Storybook/Storybook.tsx": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/shell/Drawer.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {BottomSheetViewProps} from './BottomSheet.types'
|
||||
import {type BottomSheetViewProps} from './BottomSheet.types'
|
||||
|
||||
export function BottomSheet(_: BottomSheetViewProps) {
|
||||
throw new Error('BottomSheet is not available on web')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
|
||||
|
||||
interface BackgroundNotificationPreferencesContext {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
import {ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {type ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
|
||||
|
||||
export const BackgroundNotificationHandler =
|
||||
requireNativeModule<ExpoBackgroundNotificationHandlerModule>(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
BackgroundNotificationHandlerPreferences,
|
||||
ExpoBackgroundNotificationHandlerModule,
|
||||
type BackgroundNotificationHandlerPreferences,
|
||||
type ExpoBackgroundNotificationHandlerModule,
|
||||
} from './ExpoBackgroundNotificationHandler.types'
|
||||
|
||||
// Stub for web
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import {requireNativeModule} from 'expo'
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {GifViewProps} from './GifView.types'
|
||||
import {type GifViewProps} from './GifView.types'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
|
||||
const NativeView: React.ComponentType<
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
import {AudioCategory} from './types'
|
||||
import {type AudioCategory} from './types'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoPlatformInfo')
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {AudioCategory} from './types'
|
||||
import {type AudioCategory} from './types'
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
throw new NotImplementedError()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {AudioCategory} from './types'
|
||||
import {type AudioCategory} from './types'
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {requireNativeModule} from 'expo'
|
||||
|
||||
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
|
||||
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
|
||||
|
||||
export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {SharedPrefs} from '../../index'
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
|
||||
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
|
||||
|
||||
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
|
||||
throw new NotImplementedError()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
|
||||
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
|
||||
|
||||
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
|
||||
throw new NotImplementedError()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Platform} from 'react-native'
|
||||
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
|
||||
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
|
||||
|
||||
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
|
||||
throw new NotImplementedError()
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {VisibilityViewProps} from './types'
|
||||
import {type VisibilityViewProps} from './types'
|
||||
|
||||
export async function updateActiveViewAsync() {
|
||||
throw new NotImplementedError()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import type React from 'react'
|
||||
export interface VisibilityViewProps {
|
||||
children: React.ReactNode
|
||||
onChangeStatus: (isActive: boolean) => void
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.123.0",
|
||||
"version": "1.126.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
@@ -8,7 +8,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.5.0",
|
||||
"version": "11.9.0",
|
||||
"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",
|
||||
@@ -90,10 +90,11 @@
|
||||
"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",
|
||||
"prettier": "prettier --check ."
|
||||
"prettier": "prettier --check .",
|
||||
"update-actions": "pnpm dlx actions-up --min-age 7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.9",
|
||||
"@atproto/api": "0.20.20",
|
||||
"@atproto/syntax": "0.6.1",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
@@ -103,12 +104,11 @@
|
||||
"@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/peek-menu": "^0.3.1",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@bsky.app/sift": "^0.3.8",
|
||||
"@bsky.app/tapper": "^0.5.7",
|
||||
"@bsky.app/sift": "^0.3.9",
|
||||
"@bsky.app/tapper": "^0.6.1",
|
||||
"@bsky.app/video": "0.3.6",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.12.5",
|
||||
@@ -204,6 +204,7 @@
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mediabunny": "^1.25.3",
|
||||
"multiformats": "^13.4.2",
|
||||
"nanoid": "^5.0.5",
|
||||
"normalize-url": "^8.0.0",
|
||||
@@ -331,7 +332,7 @@
|
||||
"^await-lock$": "<rootDir>/node_modules/await-lock/build/AwaitLock.js"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|@discord|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@atproto/.*|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)"
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@atproto/.*|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)"
|
||||
],
|
||||
"modulePathIgnorePatterns": [
|
||||
"__tests__/.*/__mocks__",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/src/hooks/useStableCallback.ts b/src/hooks/useStableCallback.ts
|
||||
index 1c788ab72351c21c5aea178d7b416741aa940e1f..d30f330ab1f082ea0477c19e2fa05b7a0b1a3e0d 100644
|
||||
--- a/src/hooks/useStableCallback.ts
|
||||
+++ b/src/hooks/useStableCallback.ts
|
||||
@@ -6,7 +6,7 @@ type Callback = (...args: any[]) => any;
|
||||
* https://gist.github.com/JakeCoxon/c7ebf6e6496f8468226fd36b596e1985
|
||||
*/
|
||||
export const useStableCallback = (callback: Callback) => {
|
||||
- const callbackRef = useRef<Callback>();
|
||||
+ const callbackRef = useRef<Callback>(undefined);
|
||||
const memoCallback = useCallback(
|
||||
(...args: any) => callbackRef.current && callbackRef.current(...args),
|
||||
[]
|
||||
@@ -7,52 +7,52 @@ importers:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
'@pnpm/exe':
|
||||
specifier: 11.5.0
|
||||
version: 11.5.0
|
||||
specifier: 11.9.0
|
||||
version: 11.9.0
|
||||
pnpm:
|
||||
specifier: 11.5.0
|
||||
version: 11.5.0
|
||||
specifier: 11.9.0
|
||||
version: 11.9.0
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe@11.5.0':
|
||||
resolution: {integrity: sha512-4hzOXq1HHrNPjwI8k1rt7Ot/Yrdx1JX3pn/L/M95ii1gid1Q6ZK6dVg4+gbSgUdPsYmYDZ4/Yfc0A7vd5C0ndg==}
|
||||
'@pnpm/exe@11.9.0':
|
||||
resolution: {integrity: sha512-pPPOpR79qW3nsNhlyDIdfstli4Bi78mk8r22ySxpFRwMbO8KXSjGrVzGmJBsVX39NnJTh7/WADj527nZhG9H9g==}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/linux-arm64@11.5.0':
|
||||
resolution: {integrity: sha512-NV9HdzzCB0epuI9LqZZeTaqjH3OweNQSQCS76GzEkFxJHS9e5Gvu7tgex91gxVL7bCZ+R4yr/3d3yexBFtr2ug==}
|
||||
'@pnpm/linux-arm64@11.9.0':
|
||||
resolution: {integrity: sha512-XYmY2qadHauBA3QaHi2R7fI6kt5Flje0WHz9MVrbH0kVH/XLpfOLnwPeE1+EX6K/nDa2CBvzp35VjYCNGFJa9A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linux-x64@11.5.0':
|
||||
resolution: {integrity: sha512-vH83rRx4iPk/bwm9pBVCn+5hXbcQI66I/4zk6Vc09SusJgTqOdbN4U6VhMcGIqSEdr901ksYGCyIbMv7f6Guew==}
|
||||
'@pnpm/linux-x64@11.9.0':
|
||||
resolution: {integrity: sha512-fl7W5imnSmmgXIqMQFZ/rPaVvk9OkKF8/anqHZE3XEDfWcn3BlWGndyOEas/JN7u2BXWYjs63DJZ3rnG6WOhLA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.5.0':
|
||||
resolution: {integrity: sha512-2nOnMW1rSwGv22q2yZz1HlGT3ly/Ij8wUlX0NB4n+Krx7nETRHA3MgWsbkVejxHknDcTulRVudAghuX9rgrXcw==}
|
||||
'@pnpm/linuxstatic-arm64@11.9.0':
|
||||
resolution: {integrity: sha512-fif8xbnzVEAIlvaU4yIgWKXeXYb4Kj6WMEl/KvM2x1Rp3AKAjBW/53SGzxO4cZP9doAqUzOIpMRGFVbHGeMDRw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.5.0':
|
||||
resolution: {integrity: sha512-ONOC1Mg0JusHtjzkRlre9di1QO+GAjy4HP7jMjDx21yGhrSheNdUweTXbekMH1EflRd19kTU6d8M3zewJFPtVg==}
|
||||
'@pnpm/linuxstatic-x64@11.9.0':
|
||||
resolution: {integrity: sha512-9dKu3QdShqOpnWrjW9owARpIJeP0ul8UgIIbBUv8VDGEYibt+g49zQsNaD7SdMD3WeRSjExPQ1zIhplzr5cwvQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/macos-arm64@11.5.0':
|
||||
resolution: {integrity: sha512-od0ALdTxs4A7s5vAH5q2l2phzCJb98+PVOW1rq7BGpWGeYxQ+EwvL+vq0KaO6iLsn/eVVoncCkgZ/k6QNYuTgw==}
|
||||
'@pnpm/macos-arm64@11.9.0':
|
||||
resolution: {integrity: sha512-MWzBTgeI5p3odjdVltYvFXaSWAjF2Xk5YaxiP/u2RmW8N6PHsLyIyj37Ds992CrXgFM8fO1RpvsEirozhsD6KA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/win-arm64@11.5.0':
|
||||
resolution: {integrity: sha512-9HqbI80FjVVqFx4+EPxYYNfeP9Sx69W6kYqUDvOJn9G7RJ/2NNNQ898cVHTMpXlW1/PrMEcijmdpa/NjZIrWiQ==}
|
||||
'@pnpm/win-arm64@11.9.0':
|
||||
resolution: {integrity: sha512-u/QxEcbKJZxC1t3zUYCZiHzu7TaZ/iXc6EGZoQjkeVT0LXmEVR6ypcK3ByjhIqbxQ3HGX75gnpIpR+VjRjubfw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/win-x64@11.5.0':
|
||||
resolution: {integrity: sha512-Q89CQqFGAsWmfvHZs5Kbbar45q3GBYtfAdPUCiVMVNJoLi3dsBS2LCvUq8ak3AufkFDaJBpvhaFcDP2M1NXr3A==}
|
||||
'@pnpm/win-x64@11.9.0':
|
||||
resolution: {integrity: sha512-HqJVHmZG5UKfLi38AjMl2azVmq87TlWRhwSW+f4q9LLaZeAkFyIZ7LY/pN6mh4VlH9yWj90C+tsb1yKiy7OKnw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -116,45 +116,45 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pnpm@11.5.0:
|
||||
resolution: {integrity: sha512-2/zE+Bz0hZev1Lw5H/3xLBHxqfuDo5W/prCi2cwv2P/rr9scy9UpYyFT95OQTCYVt/Cf4aNFRz/Rw1hFFyqOsQ==}
|
||||
pnpm@11.9.0:
|
||||
resolution: {integrity: sha512-vWgtXQP+Ul73yf1ngMaITR51asTJyf4AxTh4KCQxDc+Q493E9Tg18G3669UIXkGFXgvLs7YN4qxburieUDbwOw==}
|
||||
engines: {node: '>=22.13'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe@11.5.0':
|
||||
'@pnpm/exe@11.9.0':
|
||||
dependencies:
|
||||
'@reflink/reflink': 0.1.19
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@pnpm/linux-arm64': 11.5.0
|
||||
'@pnpm/linux-x64': 11.5.0
|
||||
'@pnpm/linuxstatic-arm64': 11.5.0
|
||||
'@pnpm/linuxstatic-x64': 11.5.0
|
||||
'@pnpm/macos-arm64': 11.5.0
|
||||
'@pnpm/win-arm64': 11.5.0
|
||||
'@pnpm/win-x64': 11.5.0
|
||||
'@pnpm/linux-arm64': 11.9.0
|
||||
'@pnpm/linux-x64': 11.9.0
|
||||
'@pnpm/linuxstatic-arm64': 11.9.0
|
||||
'@pnpm/linuxstatic-x64': 11.9.0
|
||||
'@pnpm/macos-arm64': 11.9.0
|
||||
'@pnpm/win-arm64': 11.9.0
|
||||
'@pnpm/win-x64': 11.9.0
|
||||
|
||||
'@pnpm/linux-arm64@11.5.0':
|
||||
'@pnpm/linux-arm64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linux-x64@11.5.0':
|
||||
'@pnpm/linux-x64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.5.0':
|
||||
'@pnpm/linuxstatic-arm64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.5.0':
|
||||
'@pnpm/linuxstatic-x64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/macos-arm64@11.5.0':
|
||||
'@pnpm/macos-arm64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-arm64@11.5.0':
|
||||
'@pnpm/win-arm64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-x64@11.5.0':
|
||||
'@pnpm/win-x64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
@@ -194,7 +194,7 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
pnpm@11.5.0: {}
|
||||
pnpm@11.9.0: {}
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
@@ -215,7 +215,6 @@ overrides:
|
||||
react-native-screens: 4.24.0
|
||||
|
||||
patchedDependencies:
|
||||
'@discord/bottom-sheet@4.6.1': 253965341bfbc8e63630dfca511c5d0e0878900e5fe414b2278bd322fc215e2a
|
||||
'@sentry/react-native@6.20.0': 1d48e4d5178f5684eb33262299e7eed283bf9601f79b9ae1af63a7f607d3483a
|
||||
expo-glass-effect@55.0.8: 6c9fa5b104e53b87df4e17b320acb3b94d12ac90c0101190045dd620681efff0
|
||||
expo-haptics@15.0.8: b33910ba2753c4eccdc885b449e6e33aa90b9cefa75ca8639762a26013141410
|
||||
@@ -242,8 +241,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.9
|
||||
version: 0.20.9
|
||||
specifier: 0.20.20
|
||||
version: 0.20.20
|
||||
'@atproto/syntax':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
@@ -272,23 +271,20 @@ importers:
|
||||
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)
|
||||
specifier: ^0.3.1
|
||||
version: 0.3.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)
|
||||
'@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)
|
||||
'@bsky.app/sift':
|
||||
specifier: ^0.3.8
|
||||
version: 0.3.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-safe-area-context@5.6.2(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)
|
||||
specifier: ^0.3.9
|
||||
version: 0.3.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-safe-area-context@5.6.2(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/tapper':
|
||||
specifier: ^0.5.7
|
||||
version: 0.5.7(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)
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.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)(tlds@1.261.0)
|
||||
'@bsky.app/video':
|
||||
specifier: 0.3.6
|
||||
version: 0.3.6(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)
|
||||
'@discord/bottom-sheet':
|
||||
specifier: bluesky-social/react-native-bottom-sheet
|
||||
version: https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908(patch_hash=253965341bfbc8e63630dfca511c5d0e0878900e5fe414b2278bd322fc215e2a)(@shopify/flash-list@2.3.1(@babel/runtime@7.29.2)(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))(@types/react@19.1.17)(react-native-gesture-handler@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-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)
|
||||
'@emoji-mart/data':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
@@ -574,6 +570,9 @@ importers:
|
||||
lodash.throttle:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
mediabunny:
|
||||
specifier: ^1.25.3
|
||||
version: 1.49.0
|
||||
multiformats:
|
||||
specifier: ^13.4.2
|
||||
version: 13.4.2
|
||||
@@ -877,32 +876,36 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@atproto/api@0.20.9':
|
||||
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
|
||||
'@atproto/api@0.20.20':
|
||||
resolution: {integrity: sha512-+yqxLCvMu36xRxwc8/x0mhptZBC+/cbfPwdzeOtlBUeVi4ZB/5RWg2L4lWWdhBSiQBZw0AgaPg/2zsx4lDFgjg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
|
||||
'@atproto/common-web@0.5.1':
|
||||
resolution: {integrity: sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lex-data@0.1.1':
|
||||
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
|
||||
'@atproto/lex-data@0.1.2':
|
||||
resolution: {integrity: sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lex-json@0.1.0':
|
||||
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
|
||||
'@atproto/lex-json@0.1.1':
|
||||
resolution: {integrity: sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lexicon@0.7.1':
|
||||
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
|
||||
'@atproto/lexicon@0.7.2':
|
||||
resolution: {integrity: sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/syntax@0.6.1':
|
||||
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/xrpc@0.8.0':
|
||||
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
|
||||
'@atproto/syntax@0.6.2':
|
||||
resolution: {integrity: sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/xrpc@0.8.1':
|
||||
resolution: {integrity: sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@babel/code-frame@7.10.4':
|
||||
@@ -1659,8 +1662,8 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky.app/peek-menu@0.2.4':
|
||||
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
|
||||
'@bsky.app/peek-menu@0.3.1':
|
||||
resolution: {integrity: sha512-ttFE7dIv1EJEmQn52WLvievzLNmPNNvPtoiz8ovl38jXLDMszL2wVPUYMnEjnZZqoq+K6HiBau9iHeCOKWwbTA==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
@@ -1672,20 +1675,21 @@ packages:
|
||||
react: '*'
|
||||
react-native: '>=0.71.0'
|
||||
|
||||
'@bsky.app/sift@0.3.8':
|
||||
resolution: {integrity: sha512-41Ug2QfjWJUApHsxcm6JDTb4r8jS6Qn6uBca8ofL10ZDKzXbSxDr1vK2yhFLyIeo/yIq3GU+XgEqrd1p6YgFiA==}
|
||||
'@bsky.app/sift@0.3.9':
|
||||
resolution: {integrity: sha512-EmwK6EGtxl6Mz4Z96J2EFi5aeiKeGiqueVCQHVms9C6ms6wMS0/HcqC/48RgFN+nSsM7Wr8sFCg1EcNnWyOQMQ==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
react-native-safe-area-context: ~5.6.0
|
||||
|
||||
'@bsky.app/tapper@0.5.7':
|
||||
resolution: {integrity: sha512-DWU4v+llcEdgwueittiCSMeahrwvQAz4g1S7M1gSSw3Zor987fF7WO/NTDWq7X/zFab2nOQ/lnCvQUuOgFCTRg==}
|
||||
'@bsky.app/tapper@0.6.1':
|
||||
resolution: {integrity: sha512-+suwTIQcZmMbFb/adzWAlAs7gzRsMmHAerzrpzZfMtt2cVrwPgOqI5IOeEEO8SmvEEImpE8IcHindG8hkCZrKA==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
tlds: '*'
|
||||
|
||||
'@bsky.app/video@0.3.6':
|
||||
resolution: {integrity: sha512-Ny7vz9v1lQOw5Llw4xejtJo/Dp/lmdRw1XdUJucOXlq6puXO93tv3mNXUIIyOEVWEwDPg8j/kSQxG/mGy+/hdQ==}
|
||||
@@ -1698,23 +1702,6 @@ packages:
|
||||
resolution: {integrity: sha512-JRrHvitc0w+r/h6XXSOq917HxsjRoxPEtyNYSmylKirjiPNrRx84VKtqFlOIWIFCOZI5BoXg6hNpE0Zq7CwVBA==}
|
||||
hasBin: true
|
||||
|
||||
'@discord/bottom-sheet@https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908':
|
||||
resolution: {gitHosted: true, integrity: sha512-zsoB8sFUx8O9aO0fKAkzoWLMyaX7EBd3ooNYjzsh9vr/c9zg/a3jOAbWzWbvM86QCZIszfbW9GXeJZKZRVk/tg==, tarball: https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908}
|
||||
version: 4.6.1
|
||||
peerDependencies:
|
||||
'@shopify/flash-list': '*'
|
||||
'@types/react': '*'
|
||||
'@types/react-native': '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
react-native-gesture-handler: '>=1.10.1'
|
||||
react-native-reanimated: 3.19.1
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-native':
|
||||
optional: true
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7':
|
||||
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -2106,12 +2093,6 @@ packages:
|
||||
'@formatjs/intl-supportedvaluesof@2.3.7':
|
||||
resolution: {integrity: sha512-ZTXEKijV5H08z19odngZPsXlmGndfukHuVnjd9qyER77XSk/yJwDMiSjoJYY5C5WxFs2UN4s/EMtu75e6Tp3ug==}
|
||||
|
||||
'@gorhom/portal@1.0.14':
|
||||
resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@growthbook/growthbook-react@1.6.5':
|
||||
resolution: {integrity: sha512-afi/RUbwazVNKv2acn6wDQz4BJNRAEpwIuHfggQup2/aE5PLAxy3+95gjjRMgCcPR0Pf3sFmhYGvOmxLD0ZRbQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3366,13 +3347,6 @@ packages:
|
||||
peerDependencies:
|
||||
webpack: '>=4.40.0'
|
||||
|
||||
'@shopify/flash-list@2.3.1':
|
||||
resolution: {integrity: sha512-7oktg2NQR7KAODjFoDaWe8/OBzyYbdTE3zQTrUBMxjIbxHTHN7UXRX1hX3DHk8KvtkgQdRfZOV8Gjj2l4fGrXw==}
|
||||
peerDependencies:
|
||||
'@babel/runtime': '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@sinclair/typebox@0.27.10':
|
||||
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
||||
|
||||
@@ -3527,6 +3501,12 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
|
||||
|
||||
'@types/dom-webcodecs@0.1.13':
|
||||
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||
|
||||
@@ -6882,6 +6862,9 @@ packages:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mediabunny@1.49.0:
|
||||
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
|
||||
|
||||
memfs@3.5.3:
|
||||
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
@@ -9493,40 +9476,40 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.9':
|
||||
'@atproto/api@0.20.20':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
'@atproto/syntax': 0.6.1
|
||||
'@atproto/xrpc': 0.8.0
|
||||
'@atproto/common-web': 0.5.1
|
||||
'@atproto/lexicon': 0.7.2
|
||||
'@atproto/syntax': 0.6.2
|
||||
'@atproto/xrpc': 0.8.1
|
||||
await-lock: 3.0.0
|
||||
multiformats: 13.4.2
|
||||
tlds: 1.261.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
'@atproto/common-web@0.5.1':
|
||||
dependencies:
|
||||
'@atproto/lex-data': 0.1.1
|
||||
'@atproto/lex-json': 0.1.0
|
||||
'@atproto/syntax': 0.6.1
|
||||
'@atproto/lex-data': 0.1.2
|
||||
'@atproto/lex-json': 0.1.1
|
||||
'@atproto/syntax': 0.6.2
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/lex-data@0.1.1':
|
||||
'@atproto/lex-data@0.1.2':
|
||||
dependencies:
|
||||
multiformats: 13.4.2
|
||||
tslib: 2.8.1
|
||||
uint8arrays: 5.1.1
|
||||
unicode-segmenter: 0.14.5
|
||||
|
||||
'@atproto/lex-json@0.1.0':
|
||||
'@atproto/lex-json@0.1.1':
|
||||
dependencies:
|
||||
'@atproto/lex-data': 0.1.1
|
||||
'@atproto/lex-data': 0.1.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/lexicon@0.7.1':
|
||||
'@atproto/lexicon@0.7.2':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/syntax': 0.6.1
|
||||
'@atproto/common-web': 0.5.1
|
||||
'@atproto/syntax': 0.6.2
|
||||
multiformats: 13.4.2
|
||||
zod: 3.25.76
|
||||
|
||||
@@ -9535,9 +9518,14 @@ snapshots:
|
||||
iso-datestring-validator: 2.2.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/xrpc@0.8.0':
|
||||
'@atproto/syntax@0.6.2':
|
||||
dependencies:
|
||||
'@atproto/lexicon': 0.7.1
|
||||
iso-datestring-validator: 2.2.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/xrpc@0.8.1':
|
||||
dependencies:
|
||||
'@atproto/lexicon': 0.7.2
|
||||
zod: 3.25.76
|
||||
|
||||
'@babel/code-frame@7.10.4':
|
||||
@@ -10479,7 +10467,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)
|
||||
|
||||
'@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)':
|
||||
'@bsky.app/peek-menu@0.3.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: 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
|
||||
@@ -10490,18 +10478,19 @@ 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/sift@0.3.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-safe-area-context@5.6.2(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/sift@0.3.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-safe-area-context@5.6.2(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)
|
||||
react-native-safe-area-context: 5.6.2(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/tapper@0.5.7(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/tapper@0.6.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)(tlds@1.261.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)
|
||||
tlds: 1.261.0
|
||||
|
||||
'@bsky.app/video@0.3.6(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:
|
||||
@@ -10519,18 +10508,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
? '@discord/bottom-sheet@https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908(patch_hash=253965341bfbc8e63630dfca511c5d0e0878900e5fe414b2278bd322fc215e2a)(@shopify/flash-list@2.3.1(@babel/runtime@7.29.2)(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))(@types/react@19.1.17)(react-native-gesture-handler@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-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:
|
||||
'@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)
|
||||
'@shopify/flash-list': 2.3.1(@babel/runtime@7.29.2)(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)
|
||||
invariant: 2.2.4
|
||||
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-gesture-handler: 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-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)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.17
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7': {}
|
||||
|
||||
'@egjs/hammerjs@2.0.17':
|
||||
@@ -11101,12 +11078,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@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:
|
||||
nanoid: 3.3.12
|
||||
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)
|
||||
|
||||
'@growthbook/growthbook-react@1.6.5(react@19.1.0)':
|
||||
dependencies:
|
||||
'@growthbook/growthbook': 1.6.5
|
||||
@@ -12614,12 +12585,6 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@shopify/flash-list@2.3.1(@babel/runtime@7.29.2)(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:
|
||||
'@babel/runtime': 7.29.2
|
||||
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)
|
||||
|
||||
'@sinclair/typebox@0.27.10': {}
|
||||
|
||||
'@sinclair/typebox@0.34.49': {}
|
||||
@@ -12804,6 +12769,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.12.4
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
dependencies:
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
'@types/dom-webcodecs@0.1.13': {}
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
dependencies:
|
||||
'@types/eslint': 9.6.1
|
||||
@@ -16720,6 +16691,11 @@ snapshots:
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
mediabunny@1.49.0:
|
||||
dependencies:
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
memfs@3.5.3:
|
||||
dependencies:
|
||||
fs-monkey: 1.1.0
|
||||
|
||||
@@ -19,7 +19,6 @@ allowBuilds:
|
||||
'esbuild': true
|
||||
'unrs-resolver': true
|
||||
patchedDependencies:
|
||||
'@discord/bottom-sheet@4.6.1': patches/@discord__bottom-sheet@4.6.1.patch
|
||||
'@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch
|
||||
'expo-glass-effect@55.0.8': patches/expo-glass-effect@55.0.8.patch
|
||||
'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
|
||||
|
||||
@@ -31,7 +31,6 @@ import {listenSessionDropped} from '#/state/events'
|
||||
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {Provider as ModalStateProvider} from '#/state/modals'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
@@ -241,22 +240,20 @@ function App() {
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<LandingProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</LandingProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<LandingProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</LandingProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
|
||||
@@ -24,7 +24,6 @@ import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||
import {listenSessionDropped} from '#/state/events'
|
||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {Provider as ModalStateProvider} from '#/state/modals'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
@@ -220,17 +219,15 @@ function App() {
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<LandingProvider>
|
||||
<InnerApp />
|
||||
</LandingProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<LandingProvider>
|
||||
<InnerApp />
|
||||
</LandingProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
|
||||
@@ -134,6 +134,7 @@ import {
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {setNavigationMetadata} from '#/analytics/metadata'
|
||||
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {InviteScannerScreen} from '#/features/inviteFriends'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
|
||||
@@ -309,6 +310,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
getComponent={() => DebugModScreen}
|
||||
options={{title: title(msg`Moderation states`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="InviteScanner"
|
||||
getComponent={() => InviteScannerScreen}
|
||||
options={{title: title(msg`Scan QR code`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SharedPreferencesTester"
|
||||
getComponent={() => SharedPreferencesTesterScreen}
|
||||
@@ -804,7 +810,7 @@ const LINKING = {
|
||||
return buildStateObject('Flat', 'Home', params)
|
||||
}
|
||||
|
||||
// Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
|
||||
// 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
|
||||
@@ -844,6 +850,8 @@ const LINKING = {
|
||||
},
|
||||
} satisfies LinkingOptions<AllNavigatorParams>
|
||||
|
||||
let didHandlePushNotificationEntry = false
|
||||
|
||||
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const ax = useAnalytics()
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
@@ -904,6 +912,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
function handlePushNotificationEntry() {
|
||||
if (!IS_NATIVE) return
|
||||
|
||||
// Only consume a launching notification once per JS runtime. Account
|
||||
// switches remount the entire tree (see `key={currentAccount?.did}` in
|
||||
// `App.native.tsx`), which re-fires `onNavigationReady` and would
|
||||
// otherwise re-process whatever `getLastNotificationResponse` still has
|
||||
// cached natively (APP-2338).
|
||||
if (didHandlePushNotificationEntry) return
|
||||
didHandlePushNotificationEntry = true
|
||||
|
||||
// intent urls are handled by `useIntentHandler`
|
||||
if (linkingUrl) return
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -399,7 +399,7 @@ function AccessSection() {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to update your location with GPS.
|
||||
</SimpleInlineLinkText>{' '}
|
||||
</SimpleInlineLinkText>
|
||||
</Trans>
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,10 @@ export enum Features {
|
||||
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
GroupChatsEnable = 'group_chats:enable',
|
||||
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
GroupChatsDisable = 'group_chats:disable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import {type Result} from '@growthbook/growthbook-react'
|
||||
|
||||
import {Logger} from '#/logger'
|
||||
import {
|
||||
@@ -169,6 +170,39 @@ export function AnalyticsContext({
|
||||
return <Context.Provider value={childContext}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* GrowthBook attribute name for the did. Must match the key used in
|
||||
* `setAttributes` (`#/analytics/features`) and the assignment unit configured
|
||||
* in the GrowthBook dashboard.
|
||||
*/
|
||||
const DID_HASH_ATTRIBUTE = 'did'
|
||||
|
||||
/**
|
||||
* Builds the session metadata override for an exposure event so the event's
|
||||
* `did` is sourced from the unit GrowthBook actually bucketed on
|
||||
* (`result.hashValue`) rather than from ambient React session metadata. This
|
||||
* keeps the `did` and the variation in sync, since both then come from the
|
||||
* same evaluation. See APP-2461.
|
||||
*
|
||||
* Only did-bucketed experiments are overridden. Experiments bucketed on
|
||||
* another attribute (e.g. `deviceId`) have a `hashValue` that is not a did, so
|
||||
* we leave their session metadata untouched and let the ambient did stand.
|
||||
*/
|
||||
function sessionMetadataForResult(
|
||||
parentContext: AnalyticsBaseContextType,
|
||||
result: Result<unknown>,
|
||||
): MergeableMetadata | undefined {
|
||||
if (result.hashAttribute !== DID_HASH_ATTRIBUTE) return undefined
|
||||
const {session} = parentContext.metadata
|
||||
if (!session) return undefined
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
did: result.hashValue,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature gates provider. Decorates the parent analytics context with
|
||||
* feature gate capabilities. Should be mounted within `AnalyticsContext`,
|
||||
@@ -185,22 +219,46 @@ export function AnalyticsFeaturesContext({
|
||||
* Side-effects: we need to synchronously set these during the same render
|
||||
* cycle. These calls do not trigger re-renders, they just set properties on
|
||||
* the singleton GrowthBook instance.
|
||||
*
|
||||
* Order matters here. We register the tracking callbacks _before_ calling
|
||||
* `setAttributes`, because `setAttributes` triggers a synchronous
|
||||
* re-evaluation that can fire exposure events. Registering first guarantees
|
||||
* those events run through this render's callback (with this render's
|
||||
* metadata) rather than a stale callback left over from a previous render,
|
||||
* e.g. after an account switch remounts this provider via the
|
||||
* `<Fragment key={did} />` breaker in `App.<platform>.tsx`. See APP-2461.
|
||||
*
|
||||
* We deliberately keep these synchronous rather than moving them into a
|
||||
* `useEffect`: `setAttributes` must run before children evaluate gates (or
|
||||
* they bucket on the previous account's attributes), and the feature usage
|
||||
* callback has no deferred-replay, so a feature evaluated before it is
|
||||
* registered would lose its `feature:viewed` event entirely.
|
||||
*/
|
||||
setAttributes(parentContext.metadata)
|
||||
feats.setTrackingCallback((experiment, result) => {
|
||||
parentContext.metric('experiment:viewed', {
|
||||
experimentId: experiment.key,
|
||||
variationId: result.key,
|
||||
})
|
||||
parentContext.metric(
|
||||
'experiment:viewed',
|
||||
{
|
||||
experimentId: experiment.key,
|
||||
variationId: result.key,
|
||||
},
|
||||
sessionMetadataForResult(parentContext, result),
|
||||
)
|
||||
})
|
||||
feats.setFeatureUsageCallback((feature, result) => {
|
||||
parentContext.metric('feature:viewed', {
|
||||
featureId: feature,
|
||||
featureResultValue: result.value,
|
||||
experimentId: result.experiment?.key,
|
||||
variationId: result.experimentResult?.key,
|
||||
})
|
||||
parentContext.metric(
|
||||
'feature:viewed',
|
||||
{
|
||||
featureId: feature,
|
||||
featureResultValue: result.value,
|
||||
experimentId: result.experiment?.key,
|
||||
variationId: result.experimentResult?.key,
|
||||
},
|
||||
result.experimentResult
|
||||
? sessionMetadataForResult(parentContext, result.experimentResult)
|
||||
: undefined,
|
||||
)
|
||||
})
|
||||
setAttributes(parentContext.metadata)
|
||||
|
||||
const childContext = useMemo<AnalyticsContextType>(() => {
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import {type Platform} from 'react-native'
|
||||
|
||||
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
|
||||
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
|
||||
import {type NotificationType} from '#/state/queries/notifications/types'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
||||
|
||||
@@ -53,6 +55,10 @@ export type Events = {
|
||||
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home'
|
||||
status: 'granted' | 'denied' | 'undetermined'
|
||||
}
|
||||
'notifications:bundleExpand': {
|
||||
notificationType: NotificationType
|
||||
authorCount: number
|
||||
}
|
||||
'state:background': {
|
||||
secondsActive: number
|
||||
}
|
||||
@@ -464,6 +470,7 @@ export type Events = {
|
||||
| 'OnboardingSuggestedAccounts'
|
||||
| 'FindContacts'
|
||||
| 'GroupChat'
|
||||
| 'NotificationExpandedProfileCard'
|
||||
}
|
||||
'profile:followers:view': {
|
||||
contextProfileDid: string
|
||||
@@ -570,6 +577,7 @@ export type Events = {
|
||||
| 'OnboardingSuggestedAccounts'
|
||||
| 'FindContacts'
|
||||
| 'GroupChat'
|
||||
| 'NotificationExpandedProfileCard'
|
||||
}
|
||||
'chat:create': {
|
||||
logContext:
|
||||
@@ -586,9 +594,86 @@ export type Events = {
|
||||
| 'SendViaChatDialog'
|
||||
| 'ConvoSettings'
|
||||
}
|
||||
// Message replies
|
||||
'chat:message:reply:send': {
|
||||
convoId: string
|
||||
isGroup: boolean
|
||||
}
|
||||
'chat:message:reply:tap': {
|
||||
convoId: string
|
||||
}
|
||||
|
||||
// Group chat adoption
|
||||
'groupchat:create': {
|
||||
logContext: 'NewChatDialog'
|
||||
}
|
||||
'groupchat:landingPage:view': {
|
||||
hasSession: boolean
|
||||
}
|
||||
'groupchat:inviteLink:redeem': {}
|
||||
|
||||
// Group chat user interactions
|
||||
'groupchat:message:send': {
|
||||
convoId: string
|
||||
isOwner: boolean
|
||||
}
|
||||
'groupchat:mute': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:unmute': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:leave': {
|
||||
convoId: string
|
||||
isOwner: boolean
|
||||
}
|
||||
'groupchat:settings:view': {
|
||||
convoId: string
|
||||
isOwner: boolean
|
||||
}
|
||||
'groupchat:inviteLink:shareButton:press': {
|
||||
convoId: string
|
||||
method: 'post' | 'copy' | 'native'
|
||||
}
|
||||
'groupchat:inviteLink:shared': {
|
||||
convoId: string
|
||||
method: 'post' | 'dm'
|
||||
}
|
||||
|
||||
// Group chat owner actions
|
||||
'groupchat:owner:editName': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:lock': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:unlock': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:kickMember': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:inviteMember': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:joinRequest:accept': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:joinRequest:reject': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:inviteLink:create': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:inviteLink:disable': {
|
||||
convoId: string
|
||||
}
|
||||
|
||||
// Group chat problems
|
||||
'groupchat:join:memberLimitReached': {
|
||||
convoId: string
|
||||
}
|
||||
|
||||
'starterPack:addUser': {
|
||||
starterPack?: string
|
||||
}
|
||||
@@ -1175,18 +1260,171 @@ 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
|
||||
}
|
||||
|
||||
/*
|
||||
* Invite friends (profile QR share sheet)
|
||||
*/
|
||||
|
||||
// NUX announcement dialog was shown to the user
|
||||
'invite:nux:presented': {}
|
||||
// user pressed "Try it" on the NUX announcement
|
||||
'invite:nux:tryItPressed': {}
|
||||
// invite friends dialog opened, with the surface that triggered it
|
||||
'invite:dialog:open': {
|
||||
logContext:
|
||||
| 'ProfileHeader'
|
||||
| 'Drawer'
|
||||
| 'FindContactsSettings'
|
||||
| 'NuxAnnouncement'
|
||||
}
|
||||
// user copied the invite link to clipboard
|
||||
'invite:action:copy': {}
|
||||
// user invoked the native share sheet with the invite link
|
||||
'invite:action:share': {}
|
||||
// user saved the QR code image to their camera roll (success only)
|
||||
'invite:action:download': {}
|
||||
// user pressed the scan button to open the QR scanner
|
||||
'invite:action:scan': {}
|
||||
// user changed the QR card color theme
|
||||
'invite:theme:change': {
|
||||
themeKey: 'dawn' | 'sunlight' | 'day' | 'dusk' | 'twilight' | 'night'
|
||||
}
|
||||
// QR scanner decoded a code; result indicates whether it resolved to a profile
|
||||
'invite:scanner:scanned': {
|
||||
result: 'profileFound' | 'invalidQr'
|
||||
}
|
||||
// empty-followers banner promoting invite/find friends was shown
|
||||
'invite:followersPromo:seen': {}
|
||||
// user pressed the empty-followers promo banner
|
||||
'invite:followersPromo:press': {}
|
||||
// user dismissed the empty-followers promo banner
|
||||
'invite:followersPromo:dismiss': {}
|
||||
|
||||
// === Video upload funnel (Frontend Spec section D) ===
|
||||
// Every event carries uploadId (client-generated UUID, ties one upload
|
||||
// session end-to-end) + engine (compression engine id, e.g.
|
||||
// native:react-native-compressor@1.13.0). jobId is added once the server
|
||||
// returns it. Sizes / codecs / dimensions / timings only - never content.
|
||||
'video:upload:picked': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
sourceMimeType?: string
|
||||
sourceBytes?: number
|
||||
sourceDurationMs?: number
|
||||
sourceWidth?: number
|
||||
sourceHeight?: number
|
||||
}
|
||||
'video:upload:compressStarted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
sourceBytes?: number
|
||||
}
|
||||
'video:upload:compressCompleted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
bytesIn?: number
|
||||
bytesOut: number
|
||||
outputMimeType: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:compressSkipped': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
skipReason: VideoCompressSkipReason
|
||||
bytes: number
|
||||
mimeType: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:compressFailed': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:uploadStarted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
bytes: number
|
||||
}
|
||||
'video:upload:uploadCompleted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
bytes: number
|
||||
elapsedMs: number
|
||||
throughputBytesPerSec: number
|
||||
}
|
||||
'video:upload:uploadFailed': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
bytes: number
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:processingStarted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
}
|
||||
'video:upload:processingCompleted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:processingFailed': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:published': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
// wall-clock from picked to published
|
||||
totalElapsedMs: number
|
||||
}
|
||||
// The event that measures the actual problem: users giving up mid-wait.
|
||||
'video:upload:abandoned': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
phase: 'compress' | 'upload' | 'processing'
|
||||
jobId?: string
|
||||
elapsedInPhaseMs: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import {Pressable} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const positionStyles = {
|
||||
'top-left': {
|
||||
top: a.p_xs.padding,
|
||||
left: a.p_xs.padding,
|
||||
},
|
||||
'top-right': {
|
||||
top: a.p_xs.padding,
|
||||
right: a.p_xs.padding,
|
||||
},
|
||||
'bottom-left': {
|
||||
bottom: a.p_xs.padding,
|
||||
left: a.p_xs.padding,
|
||||
},
|
||||
'bottom-right': {
|
||||
bottom: a.p_xs.padding,
|
||||
right: a.p_xs.padding,
|
||||
},
|
||||
}
|
||||
|
||||
export function AltBadgeWithDialog({
|
||||
text,
|
||||
position,
|
||||
}: {
|
||||
text: string
|
||||
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const large = useLargeAltBadgeEnabled()
|
||||
const control = Prompt.usePromptControl()
|
||||
|
||||
const pos = position ? [a.absolute, positionStyles[position]] : {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
testID="altBadgeWithDialogButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Show alt text`}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_20}
|
||||
onPress={control.open}
|
||||
style={s => [
|
||||
a.justify_center,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
a.z_10,
|
||||
t.atoms.bg_contrast_25,
|
||||
large && {
|
||||
padding: 6,
|
||||
},
|
||||
{
|
||||
opacity: 0.8,
|
||||
},
|
||||
pos,
|
||||
s.hovered || s.pressed
|
||||
? [
|
||||
{
|
||||
opacity: 1,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
]}>
|
||||
<Text
|
||||
accessible={false}
|
||||
style={[a.font_bold, large ? a.text_xs : {fontSize: 8}]}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.Content>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Alt Text</Trans>
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
|
||||
</Prompt.Content>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Cancel cta={l`Close`} />
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {resetPostsFeedQueries} from '#/state/queries/post-feed'
|
||||
import {atoms as a, platform, useTheme} from '#/alf'
|
||||
import {atoms as a, platform, useTheme, web} from '#/alf'
|
||||
import * as Select from '#/components/Select'
|
||||
import {Button} from './Button'
|
||||
import {Button, ButtonIcon} from './Button'
|
||||
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from './icons/Globe'
|
||||
|
||||
export function AppLanguageDropdown() {
|
||||
const t = useTheme()
|
||||
@@ -57,6 +58,7 @@ export function AppLanguageDropdown() {
|
||||
native: [a.gap_xs],
|
||||
}),
|
||||
]}>
|
||||
<ButtonIcon icon={EarthIcon} size="md" />
|
||||
<Select.ValueText
|
||||
placeholder={_(msg`Select an app language`)}
|
||||
style={[t.atoms.text_contrast_medium]}
|
||||
@@ -68,7 +70,7 @@ export function AppLanguageDropdown() {
|
||||
<Select.Content
|
||||
label={_(msg`Select language`)}
|
||||
renderItem={({label, value}) => (
|
||||
<Select.Item value={value} label={label}>
|
||||
<Select.Item value={value} label={label} style={web([a.pointer])}>
|
||||
<Select.ItemIndicator />
|
||||
<Select.ItemText>{label}</Select.ItemText>
|
||||
</Select.Item>
|
||||
|
||||
@@ -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'
|
||||
@@ -33,10 +31,10 @@ export function AvatarBubbles({
|
||||
profiles: allProfiles,
|
||||
self = false,
|
||||
size = 120,
|
||||
moderationOpts,
|
||||
count,
|
||||
}: {
|
||||
animate?: boolean
|
||||
profiles: (bsky.profile.AnyProfileView | undefined)[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
/**
|
||||
* 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).
|
||||
@@ -45,19 +43,21 @@ export function AvatarBubbles({
|
||||
*/
|
||||
self?: boolean
|
||||
size?: number
|
||||
moderationOpts?: ModerationOpts
|
||||
/**
|
||||
* The true number of members, used to decide how many bubbles to render when
|
||||
* it exceeds the number of `profiles` we have on hand (e.g. an invite preview
|
||||
* that only carries a few member profiles for a much larger group). Slots
|
||||
* without a profile render as placeholders. Defaults to `profiles.length`.
|
||||
*/
|
||||
count?: number
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const profiles =
|
||||
!self && allProfiles.length > 2
|
||||
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
|
||||
? allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||
: allProfiles
|
||||
const moderations = useMemo(() => {
|
||||
if (!moderationOpts) return []
|
||||
return profiles.map(p => {
|
||||
return p && moderateProfile(p, moderationOpts)
|
||||
})
|
||||
}, [profiles, moderationOpts])
|
||||
|
||||
const bubbleCount = Math.max(profiles.length, count ?? 0)
|
||||
|
||||
const scale = size / 120
|
||||
const marginOffset = size < 120 ? -2 : 0
|
||||
@@ -89,7 +89,7 @@ export function AvatarBubbles({
|
||||
}, [animate, p0, p1, p2, p3])
|
||||
|
||||
const scales = [p0, p1, p2, p3]
|
||||
const layouts = getLayouts(profiles.length)
|
||||
const layouts = getLayouts(bubbleCount)
|
||||
|
||||
return (
|
||||
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
|
||||
@@ -110,7 +110,6 @@ export function AvatarBubbles({
|
||||
y={layout.y}
|
||||
zIndex={layout.zIndex}
|
||||
includeProfileBorder={layout.border}
|
||||
moderation={moderations[i]?.ui('avatar')}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -119,14 +118,13 @@ export function AvatarBubbles({
|
||||
}
|
||||
|
||||
function AvatarBubble({
|
||||
profile,
|
||||
profile: profileUnshadowed,
|
||||
scale,
|
||||
size,
|
||||
x,
|
||||
y,
|
||||
zIndex,
|
||||
includeProfileBorder,
|
||||
moderation,
|
||||
}: {
|
||||
profile?: bsky.profile.AnyProfileView
|
||||
scale: SharedValue<number>
|
||||
@@ -135,14 +133,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={[
|
||||
@@ -156,14 +155,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} />
|
||||
|
||||