Merge remote-tracking branch 'origin/main' into qr-colors

* origin/main: (37 commits)
  Tweak strings and context (#10928)
  Fix internal repo sync broken by actions/checkout v6 bump (#10933)
  Fix setState-in-render warning from convo cache subscription (#10934)
  Add light haptics to Edit Profile Button for labellers (#10948)
  Remove old message composer (#10951)
  Fix embeds overlapping each other in chat (#10949)
  Add mark all as read to chat settings (#10973)
  Add TestFlight group selection to iOS build workflow (#10979)
  Nightly source-language update
  bskyweb: add isPartOf jsonld post attr (#10945)
  Process videos on web with WebCodecs (#10955)
  Nightly source-language update
  Restore ALT badges in quoted posts (#10964)
  Fix nightly build workflow (#10943)
  Bump version to 1.126.0 (#10942)
  Add nightly iOS/Android build workflow (#10940)
  Simplify Android build: bundletool + eas submit (#9855)
  v1.125 release prep (#10936)
  Style follow buttons in expanded bundles, add client event on expand (#10915)
  Flash message bubble when tapping a reply (#10929)
  ...
This commit is contained in:
Eric Bailey
2026-06-24 11:43:13 -05:00
178 changed files with 36766 additions and 32858 deletions
@@ -22,13 +22,13 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Docker buildx - 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 }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}} username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -51,7 +51,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@v4 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
@@ -23,13 +23,13 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Docker buildx - 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 }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME }} username: ${{ env.USERNAME }}
@@ -37,7 +37,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -46,7 +46,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@v4 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
@@ -22,13 +22,13 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Docker buildx - 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 }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}} username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@v4 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
@@ -22,13 +22,13 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Docker buildx - 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 }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}} username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@v4 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
push: true push: true
@@ -22,13 +22,13 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Docker buildx - 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 }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}} username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@v4 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
push: true push: true
+129 -71
View File
@@ -10,6 +10,23 @@ on:
options: options:
- testflight-android - testflight-android
- production - 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: jobs:
build: build:
@@ -19,6 +36,10 @@ jobs:
concurrency: concurrency:
group: android-build group: android-build
cancel-in-progress: false 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: steps:
- name: Check for EXPO_TOKEN - name: Check for EXPO_TOKEN
run: > run: >
@@ -28,32 +49,32 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 5 fetch-depth: 5
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
- name: 🪛 Setup jq - name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2 uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
- name: ⚙️ Install dependencies - name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI - name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@v9 uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with: with:
eas-version: '19.0.5' eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider' packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@v5 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: "temurin" distribution: "temurin"
java-version: "17" java-version: "17"
@@ -62,7 +83,8 @@ jobs:
run: pnpm intl:build 2>&1 | tee i18n.log run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors - 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 # EXPO_PUBLIC_ENV is handled in eas.json
- name: Env - name: Env
@@ -92,99 +114,76 @@ jobs:
--profile $PROFILE --profile $PROFILE
--local --output build.aab --non-interactive --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 - name: 📚 Get version from package.json
id: get-build-info id: get-build-info
run: bash scripts/setGitHubOutput.sh 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' }} if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3 uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
payload-templated: true payload-templated: true
payload: | 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 - name: 🔧 Setup bundletool
if: ${{ inputs.profile != 'production' }} uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
uses: slackapi/slack-github-action@v3.0.3
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} bundletool-version: "1.17.2"
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 }}"}
- name: 🧹 Clear Metro cache - name: 🔑 Decode keystore
if: ${{ inputs.profile == 'production' }} run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
# https://github.com/expo/eas-cli/issues/2959#issuecomment-2749791326 keystore.jks
run: rm -rf ${TMPDIR:-/tmp}/metro-cache
- name: 🏗️ Build Production APK - name: 📦 Build signed universal APK
if: ${{ inputs.profile == 'production' }} run: |
run: > bundletool build-apks \
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} --bundle=build.aab \
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }} --output=universal.apks \
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }} --mode=universal \
pnpm use-build-number-with-bump --ks=keystore.jks \
pnpm eas build -p android --ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
--profile production-apk --ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
--local --output build.apk --non-interactive --key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
- name: 🚀 Upload Production APK Artifact - name: 📋 Rename to .zip for extraction
id: upload-artifact-production-apk run: mv universal.apks universal.zip
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v7 - 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: with:
retention-days: 30 retention-days: 30
compression-level: 6 compression-level: 6
name: build-${{ steps.timestamp.outputs.time }}.apk name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk path: build.apk
- name: 🔔 Notify Slack of Production APK Build - name: 🔔 Notify Slack of APK Artifact
if: ${{ inputs.profile == 'production' }} uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@v3.0.3
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
payload-templated: true payload-templated: true
payload: | 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 - name: ⬇️ Restore Cache
id: get-base-commit id: get-base-commit
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: ${{ inputs.profile == 'testflight-android' }} if: ${{ inputs.profile == 'testflight-android' }}
with: with:
path: most-recent-testflight-commit.txt path: most-recent-testflight-commit.txt
@@ -195,3 +194,62 @@ jobs:
env: env:
GITHUB_SHA: ${{ github.sha }} GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt 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 }}```"}
+111 -15
View File
@@ -10,6 +10,35 @@ on:
options: options:
- testflight - testflight
- production - 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: jobs:
build: build:
@@ -19,6 +48,9 @@ jobs:
concurrency: concurrency:
group: ios-build group: ios-build
cancel-in-progress: false cancel-in-progress: false
outputs:
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
build-number: ${{ steps.ipa-build-number.outputs.build-number }}
steps: steps:
- name: Check for EXPO_TOKEN - name: Check for EXPO_TOKEN
run: > run: >
@@ -28,42 +60,48 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 5 fetch-depth: 5
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
- name: 🪛 Setup jq - name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2 uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
- name: ⚙️ Install dependencies - name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI - name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@v9 uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with: with:
eas-version: '19.0.5' eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider' packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
- uses: maxim-lobanov/setup-xcode@v1 - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with: with:
xcode-version: "26.4" xcode-version: "26.4"
- name: ☕️ Setup Cocoapods - name: ☕️ Assert Cocoapods version
uses: maxim-lobanov/setup-cocoapods@v1 run: |
with: EXPECTED=1.16.2
version: 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 - name: 💾 Cache Pods
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: pods-cache id: pods-cache
with: with:
path: ./ios/Pods path: ./ios/Pods
@@ -75,7 +113,8 @@ jobs:
run: pnpm intl:build 2>&1 | tee i18n.log run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors - 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 # EXPO_PUBLIC_ENV is handled in eas.json
- name: ✏️ Write environment variables - name: ✏️ Write environment variables
@@ -153,19 +192,76 @@ jobs:
id: get-build-info id: get-build-info
run: bash scripts/setGitHubOutput.sh 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 - name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }} if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3 uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
payload-templated: true payload-templated: true
payload: | 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 - name: ⬇️ Restore Cache
id: get-base-commit id: get-base-commit
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: ${{ inputs.profile == 'testflight' }} if: ${{ inputs.profile == 'testflight' }}
with: with:
path: most-recent-testflight-commit.txt path: most-recent-testflight-commit.txt
+80 -33
View File
@@ -18,6 +18,10 @@ on:
description: Runtime version (in x.x.x format) that this update is for description: Runtime version (in x.x.x format) that this update is for
required: true required: true
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
permissions:
contents: read
jobs: jobs:
bundleDeploy: bundleDeploy:
if: github.repository == 'bluesky-social/social-app' if: github.repository == 'bluesky-social/social-app'
@@ -48,7 +52,7 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -56,17 +60,17 @@ jobs:
if: ${{ github.ref != 'refs/heads/main' }} if: ${{ github.ref != 'refs/heads/main' }}
run: git fetch origin main:main --depth 100 run: git fetch origin main:main --depth 100
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
- name: 📷 Check fingerprint and install dependencies - name: 📷 Check fingerprint and install dependencies
id: fingerprint id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@main uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
with: with:
profile: ${{ inputs.channel || 'testflight' }} profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }} previous-commit-tag: ${{ inputs.runtimeVersion }}
@@ -75,7 +79,8 @@ jobs:
run: pnpm intl:build 2>&1 | tee i18n.log run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors - 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 - name: Lint check
run: pnpm lint run: pnpm lint
@@ -87,7 +92,7 @@ jobs:
run: pnpm typecheck run: pnpm typecheck
- name: 🔨 Setup EAS - name: 🔨 Setup EAS
uses: expo/expo-github-action@v9 uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
if: ${{ !steps.fingerprint.outputs.includes-changes }} if: ${{ !steps.fingerprint.outputs.includes-changes }}
with: with:
eas-version: '19.0.5' eas-version: '19.0.5'
@@ -96,7 +101,7 @@ jobs:
- name: 🪛 Setup jq - name: 🪛 Setup jq
if: ${{ !steps.fingerprint.outputs.includes-changes }} 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 # eas.json not used here, set EXPO_PUBLIC_ENV
- name: Env - name: Env
@@ -137,7 +142,7 @@ jobs:
- name: ⬇️ Restore Cache - name: ⬇️ Restore Cache
id: get-base-commit id: get-base-commit
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: ${{ !steps.fingerprint.outputs.includes-changes }} if: ${{ !steps.fingerprint.outputs.includes-changes }}
with: with:
path: most-recent-testflight-commit.txt path: most-recent-testflight-commit.txt
@@ -157,7 +162,9 @@ jobs:
needs: [bundleDeploy] needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here # 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: steps:
- name: Check for EXPO_TOKEN - name: Check for EXPO_TOKEN
run: > run: >
@@ -167,20 +174,20 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 5 fetch-depth: 5
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
- name: 🔨 Setup EAS - name: 🔨 Setup EAS
uses: expo/expo-github-action@v9 uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with: with:
eas-version: '19.0.5' eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider' packager: 'pnpm --allow-build=dtrace-provider'
@@ -189,17 +196,23 @@ jobs:
- name: ⚙️ Install dependencies - name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@v1 - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with: with:
xcode-version: "26.4" xcode-version: "26.4"
- name: ☕️ Setup Cocoapods - name: ☕️ Assert Cocoapods version
uses: maxim-lobanov/setup-cocoapods@v1 run: |
with: EXPECTED=1.16.2
version: 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 - name: 💾 Cache Pods
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: pods-cache id: pods-cache
with: with:
path: ./ios/Pods path: ./ios/Pods
@@ -282,7 +295,7 @@ jobs:
- name: ⬇️ Restore Cache - name: ⬇️ Restore Cache
id: get-base-commit id: get-base-commit
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: ${{ inputs.channel == 'testflight' }} if: ${{ inputs.channel == 'testflight' }}
with: with:
path: most-recent-testflight-commit.txt path: most-recent-testflight-commit.txt
@@ -303,7 +316,9 @@ jobs:
needs: [bundleDeploy] needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here # 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: steps:
- name: Check for EXPO_TOKEN - name: Check for EXPO_TOKEN
@@ -314,26 +329,26 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 5 fetch-depth: 5
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
- name: 🔨 Setup EAS - name: 🔨 Setup EAS
uses: expo/expo-github-action@v9 uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with: with:
eas-version: '19.0.5' eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider' packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@v5 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: "temurin" distribution: "temurin"
java-version: "17" java-version: "17"
@@ -368,17 +383,49 @@ jobs:
pnpm use-build-number-with-bump pnpm use-build-number-with-bump
pnpm eas build -p android pnpm eas build -p android
--profile testflight-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 - name: ⏰ Get a timestamp
id: timestamp id: timestamp
uses: nanzm/get-time-action@master run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT"
with:
format: "MM-DD-HH-mm-ss"
- name: 🚀 Upload Artifact - name: 🚀 Upload Artifact
id: upload-artifact id: upload-artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
retention-days: 30 retention-days: 30
compression-level: 0 compression-level: 0
@@ -386,7 +433,7 @@ jobs:
path: build.apk path: build.apk
- name: 🔔 Notify Slack - name: 🔔 Notify Slack
uses: slackapi/slack-github-action@v3.0.3 uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
@@ -396,7 +443,7 @@ jobs:
- name: ⬇️ Restore Cache - name: ⬇️ Restore Cache
id: get-base-commit id: get-base-commit
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }} if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
with: with:
path: most-recent-testflight-commit.txt path: most-recent-testflight-commit.txt
+3 -3
View File
@@ -54,18 +54,18 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Configure AWS credentials (OIDC) - name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v6 uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
with: with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2 aws-region: us-east-2
- name: Claude - name: Claude
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
with: with:
use_bedrock: 'true' use_bedrock: 'true'
additional_permissions: | additional_permissions: |
+3 -3
View File
@@ -40,18 +40,18 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Configure AWS credentials (OIDC) - name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v6 uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
with: with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2 aws-region: us-east-2
- name: Claude review - name: Claude review
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
with: with:
use_bedrock: 'true' use_bedrock: 'true'
additional_permissions: | additional_permissions: |
+7 -4
View File
@@ -10,14 +10,17 @@ concurrency:
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}" group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
build-and-test: build-and-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Git Checkout - name: Git Checkout
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go tooling - name: Set up Go tooling
uses: actions/setup-go@v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with: with:
go-version-file: bskyweb/go.mod go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum cache-dependency-path: bskyweb/go.sum
@@ -33,9 +36,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Git Checkout - name: Git Checkout
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go tooling - name: Set up Go tooling
uses: actions/setup-go@v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with: with:
go-version-file: bskyweb/go.mod go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum cache-dependency-path: bskyweb/go.sum
+6 -6
View File
@@ -24,7 +24,7 @@ jobs:
job: [lint, prettier, typecheck] job: [lint, prettier, typecheck]
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Verify Node version pins match package.json - name: Verify Node version pins match package.json
run: | run: |
set -euo pipefail 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) 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" check "eas.json" "$v"
exit $rc exit $rc
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node - name: Install node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
@@ -87,10 +87,10 @@ jobs:
shard: [1, 2, 3, 4] shard: [1, 2, 3, 4]
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node - name: Install node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
+173
View File
@@ -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: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}} ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node - name: Install node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
@@ -30,12 +30,12 @@ jobs:
- name: Extract language strings - name: Extract language strings
run: pnpm intl:extract run: pnpm intl:extract
- name: Create commit - name: Create commit
uses: stefanzweifel/git-auto-commit-action@v5 uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
with: with:
commit_message: Nightly source-language update commit_message: Nightly source-language update
file_pattern: ./src/locale/locales/en/messages.po file_pattern: ./src/locale/locales/en/messages.po
- name: Push source lang to Crowdin - name: Push source lang to Crowdin
uses: crowdin/github-action@v2 uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with: with:
upload_sources: true upload_sources: true
upload_sources_args: "-b main" upload_sources_args: "-b main"
+28 -16
View File
@@ -5,16 +5,15 @@ on:
issue_comment: issue_comment:
types: [created] types: [created]
# Permissiosn to make comments in the pull request # Permissions are granted per-job below; anything unlisted defaults to none
permissions: permissions: {}
pull-requests: write
actions: write
contents: read
jobs: jobs:
handle-comment: handle-comment:
if: github.event.issue.pull_request if: github.event.issue.pull_request
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
outputs: outputs:
should-deploy: ${{ steps.check-org.outputs.result }} should-deploy: ${{ steps.check-org.outputs.result }}
@@ -45,10 +44,10 @@ jobs:
echo "mentioned=false" >> $GITHUB_OUTPUT echo "mentioned=false" >> $GITHUB_OUTPUT
fi fi
- name: Check organization membership - name: Check commenter has write access
if: steps.check-mention.outputs.mentioned == 'true' if: steps.check-mention.outputs.mentioned == 'true'
id: check-org id: check-org
uses: actions/github-script@v7 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
script: | script: |
try { try {
@@ -72,13 +71,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [handle-comment] needs: [handle-comment]
if: needs.handle-comment.outputs.should-deploy == 'true' if: needs.handle-comment.outputs.should-deploy == 'true'
permissions:
contents: read
pull-requests: write
steps: steps:
- name: Get PR HEAD SHA - name: Get PR HEAD SHA
env: env:
ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_NUMBER: ${{ github.event.issue.number }}
id: pr-info id: pr-info
uses: actions/github-script@v7 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
script: | script: |
const pr = await github.rest.pulls.get({ const pr = await github.rest.pulls.get({
@@ -87,6 +89,16 @@ jobs:
pull_number: process.env.ISSUE_NUMBER, 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 SHA: ${pr.data.head.sha}`);
console.log(`PR HEAD REF: ${pr.data.head.ref}`); console.log(`PR HEAD REF: ${pr.data.head.ref}`);
@@ -94,7 +106,7 @@ jobs:
core.setOutput('head-ref', pr.data.head.ref); core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3 uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with: with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }} header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }} number: ${{ github.event.issue.number }}
@@ -113,14 +125,14 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ steps.pr-info.outputs.head-sha }} ref: ${{ steps.pr-info.outputs.head-sha }}
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
@@ -141,14 +153,14 @@ jobs:
run: pnpm typecheck run: pnpm typecheck
- name: 🔨 Setup EAS - name: 🔨 Setup EAS
uses: expo/expo-github-action@v9 uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with: with:
eas-version: '19.0.5' eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider' packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
- name: 🪛 Setup jq - name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2 uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
- name: Env - name: Env
id: env id: env
@@ -181,7 +193,7 @@ jobs:
RUNTIME_VERSION: RUNTIME_VERSION:
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3 uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
env: env:
ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_NUMBER: ${{ github.event.issue.number }}
with: with:
@@ -198,7 +210,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3 uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: failure() if: failure()
with: with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }} header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
+22 -16
View File
@@ -13,25 +13,28 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: # Permissions are granted per-job below; anything unlisted defaults to none.
pull-requests: write # pull-requests: write is needed by sticky-pull-request-comment to post the
actions: write # bundle-size and fingerprint diffs
contents: read permissions: {}
jobs: jobs:
webpack-analyzer: webpack-analyzer:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
permissions:
contents: read
pull-requests: write
steps: steps:
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
@@ -68,7 +71,7 @@ jobs:
- name: ⬇️ Get base stats from cache - name: ⬇️ Get base stats from cache
id: get-base-stats id: get-base-stats
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: stats-base.json path: stats-base.json
key: stats-base-${{ steps.base-commit.outputs.base-commit }} key: stats-base-${{ steps.base-commit.outputs.base-commit }}
@@ -89,14 +92,14 @@ jobs:
- name: % Get diff - name: % Get diff
id: get-diff id: get-diff
uses: NejcZdovc/bundle-size-diff@v1 uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
with: with:
base_path: "stats-base.json" base_path: "stats-base.json"
pr_path: "../stats-new.json" pr_path: "../stats-new.json"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico" excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
- name: 💬 Drop a comment - name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3 uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with: with:
header: bundle-diff header: bundle-diff
message: | message: |
@@ -108,9 +111,12 @@ jobs:
fingerprint-native: fingerprint-native:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
permissions:
contents: read
pull-requests: write
steps: steps:
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 100 fetch-depth: 100
@@ -118,22 +124,22 @@ jobs:
run: git fetch origin main:main --depth 100 run: git fetch origin main:main --depth 100
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
- name: 📷 Check fingerprint and install dependencies - name: 📷 Check fingerprint and install dependencies
id: fingerprint id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@main uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
with: with:
profile: pull-request profile: pull-request
- name: 💬 Drop a comment - 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 }} if: ${{ steps.fingerprint.outputs.includes-changes }}
with: with:
header: fingerprint-diff header: fingerprint-diff
@@ -151,7 +157,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Delete comment - 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 }} if: ${{ !steps.fingerprint.outputs.includes-changes }}
with: with:
header: fingerprint-diff header: fingerprint-diff
+12 -3
View File
@@ -4,28 +4,37 @@ on:
push: push:
branches: [main] branches: [main]
# The push to the internal repo uses the app token below, not the GITHUB_TOKEN
permissions:
contents: read
jobs: jobs:
sync: sync:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'bluesky-social/social-app' if: github.repository == 'bluesky-social/social-app'
steps: steps:
- name: Checkout public repo - name: Checkout public repo
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 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 - name: Generate GitHub App Token
id: app-token id: app-token
uses: actions/create-github-app-token@v3 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with: with:
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }} app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }} private-key: ${{ secrets.SYNC_INTERNAL_PK }}
repositories: social-app-internal 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 - name: Push to internal repo
env: env:
TOKEN: ${{ steps.app-token.outputs.token }} TOKEN: ${{ steps.app-token.outputs.token }}
run: | run: |
git config user.name "github-actions" git config user.name "github-actions"
git config user.email "test@users.noreply.github.com" 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 remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
git push internal main --force git push internal main --force
+6 -3
View File
@@ -6,13 +6,16 @@ concurrency:
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}" group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
verify-pnpm-lock: verify-pnpm-lock:
name: No manual pnpm-lock.yaml edits name: No manual pnpm-lock.yaml edits
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out PR HEAD - name: Check out PR HEAD
uses: actions/checkout@v5 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -21,10 +24,10 @@ jobs:
BASE_REF: ${{ github.base_ref }} BASE_REF: ${{ github.base_ref }}
run: git fetch origin $BASE_REF --depth=1 run: git fetch origin $BASE_REF --depth=1
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node - name: Install node
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json node-version-file: package.json
+37
View File
@@ -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
+4 -4
View File
@@ -145,21 +145,21 @@ appId: xyz.blueskyweb.app
id: "profileHeaderDropdownBtn" id: "profileHeaderDropdownBtn"
- tapOn: "Add to Lists" - tapOn: "Add to Lists"
- assertVisible: - assertVisible:
id: "userAddRemoveListsModal" id: "userAddRemoveListsDialog"
- tapOn: - tapOn:
id: "user-bob.test-addBtn" id: "user-bob.test-addBtn"
- tapOn: - tapOn:
id: "doneBtn" id: "doneBtn"
- assertNotVisible: - assertNotVisible:
id: "userAddRemoveListsModal" id: "userAddRemoveListsDialog"
- tapOn: - tapOn:
id: "profileHeaderDropdownBtn" id: "profileHeaderDropdownBtn"
- tapOn: "Add to Lists" - tapOn: "Add to Lists"
- assertVisible: - assertVisible:
id: "userAddRemoveListsModal" id: "userAddRemoveListsDialog"
- tapOn: - tapOn:
id: "user-bob.test-addBtn" id: "user-bob.test-addBtn"
- tapOn: - tapOn:
id: "doneBtn" id: "doneBtn"
- assertNotVisible: - assertNotVisible:
id: "userAddRemoveListsModal" id: "userAddRemoveListsDialog"

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

+48 -2
View File
@@ -65,6 +65,7 @@ type discussionForumPosting struct {
CommentCount *int64 `json:"commentCount,omitempty"` CommentCount *int64 `json:"commentCount,omitempty"`
Comment []comment `json:"comment,omitempty"` Comment []comment `json:"comment,omitempty"`
IsBasedOn string `json:"isBasedOn,omitempty"` IsBasedOn string `json:"isBasedOn,omitempty"`
IsPartOf string `json:"isPartOf,omitempty"`
SharedContent *sharedContent `json:"sharedContent,omitempty"` SharedContent *sharedContent `json:"sharedContent,omitempty"`
} }
@@ -344,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
return "" 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 // buildAuthor constructs a Person. Organization classification for
// custom-domain accounts is a future enhancement. // custom-domain accounts is a future enhancement.
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg { func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
@@ -584,13 +622,21 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
// buildPostJSONLD marshals the WebPage envelope wrapping a // buildPostJSONLD marshals the WebPage envelope wrapping a
// DiscussionForumPosting. canonicalURL is used for both envelope.url and // DiscussionForumPosting. canonicalURL is used for both envelope.url and
// (as a fallback) mainEntity.url so they always agree. // (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) { // 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 { if pv == nil || pv.Author == nil {
return "", fmt.Errorf("nil post view or author") return "", fmt.Errorf("nil post view or author")
} }
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels) node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
if isPartOfURL != "" {
node.IsPartOf = isPartOfURL
}
// mainEntity.url is empty when the author handle is unusable; fall back // mainEntity.url is empty when the author handle is unusable; fall back
// to canonicalURL so it agrees with envelope.url. // to canonicalURL so it agrees with envelope.url.
if node.URL == "" { if node.URL == "" {
+155 -36
View File
@@ -183,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. // withSelfLabel adds a self-label that should hide embeds.
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) { func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) { return func(pv *appbsky.FeedDefs_PostView) {
@@ -263,7 +276,7 @@ func unmarshalLD(t *testing.T, s string) map[string]any {
func TestBuildPostJSONLD_Bare(t *testing.T) { func TestBuildPostJSONLD_Bare(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -333,7 +346,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg" 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" 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)) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -358,7 +371,7 @@ func TestBuildPostJSONLD_WithGallery(t *testing.T) {
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@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" 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)) 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) out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -385,7 +398,7 @@ func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg" 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", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb)) withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -438,7 +451,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
func TestBuildPostJSONLD_WithVideo(t *testing.T) { func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg" 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)) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["thumbnailUrl"] != thumb { if main["thumbnailUrl"] != thumb {
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"]) t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
@@ -451,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
func TestBuildPostJSONLD_QuotePost(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")) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" { if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"]) t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
@@ -460,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) { func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked()) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isBasedOn"]; present { if _, present := main["isBasedOn"]; present {
t.Errorf("blocked quote should not produce isBasedOn") 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) { 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")) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
sc, ok := main["sharedContent"].(map[string]any) sc, ok := main["sharedContent"].(map[string]any)
if !ok { if !ok {
@@ -487,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
thumb := "https://cdn.bsky.app/img/x@jpeg" thumb := "https://cdn.bsky.app/img/x@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withImages(thumb), withSelfLabel("porn")) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present { if _, present := main["image"]; present {
t.Errorf("hidden-embed post should not emit image") t.Errorf("hidden-embed post should not emit image")
@@ -506,7 +625,7 @@ func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
thumb := "https://cdn.bsky.app/img/g@jpeg" thumb := "https://cdn.bsky.app/img/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withGallery(thumb), withSelfLabel("porn")) withGallery(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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present { if _, present := main["image"]; present {
t.Errorf("hidden-embed gallery post should not emit image") t.Errorf("hidden-embed gallery post should not emit image")
@@ -520,7 +639,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, </script>, and a unicode char. // Includes ", \, newline, </script>, and a unicode char.
tricky := "hello \"world\" \\ <\\>\n</script> 🎉" tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -556,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"}, 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if cc := main["commentCount"].(float64); int64(cc) != 14 { if cc := main["commentCount"].(float64); int64(cc) != 14 {
@@ -598,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) { func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello") pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123" 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) envelope := unmarshalLD(t, out)
main := envelope["mainEntity"].(map[string]any) main := envelope["mainEntity"].(map[string]any)
// mainEntity.url falls back to the caller's canonical URL so envelope // mainEntity.url falls back to the caller's canonical URL so envelope
@@ -647,7 +766,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi") 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) env := unmarshalLD(t, out)
main := env["mainEntity"].(map[string]any) main := env["mainEntity"].(map[string]any)
if env["url"] != tc.canonical { if env["url"] != tc.canonical {
@@ -664,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
// Defensive: don't panic if Author is nil. // Defensive: don't panic if Author is nil.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
pv.Author = nil 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") t.Errorf("expected error for nil-author post, got nil")
} }
} }
@@ -682,7 +801,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}}, {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}}, {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 { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -703,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{ replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}}, {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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
imgs, ok := c["image"].([]any) imgs, ok := c["image"].([]any)
@@ -910,7 +1029,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply") good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply", bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withPostLabel("!hide", false)) 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) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri { if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected only the unlabeled reply to remain, got %v", ids) t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
@@ -923,7 +1042,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply") good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply", bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withSelfLabel("spam")) withSelfLabel("spam"))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri { if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected self-labeled reply dropped, got %v", ids) t.Errorf("expected self-labeled reply dropped, got %v", ids)
@@ -939,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
// union behavior. // union behavior.
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply", bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
withPostLabel("self-harm", false)) 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) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri { if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected embed-labeled reply dropped, got %v", ids) t.Errorf("expected embed-labeled reply dropped, got %v", ids)
@@ -951,7 +1070,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply", reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
withPostLabel("!hide", true)) withPostLabel("!hide", true))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out) ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != reply.Uri { if len(ids) != 1 || ids[0] != reply.Uri {
t.Errorf("expected negated-label reply to be kept, got %v", ids) t.Errorf("expected negated-label reply to be kept, got %v", ids)
@@ -962,7 +1081,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
// Reply author should also carry a DID identifier. // Reply author should also carry a DID identifier.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi") 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
auth, ok := c["author"].(map[string]any) auth, ok := c["author"].(map[string]any)
@@ -1103,7 +1222,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
}) })
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
withVerifications(state)) withVerifications(state))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1143,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi", reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
withVerifications(state)) 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) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
auth := c["author"].(map[string]any) auth := c["author"].(map[string]any)
@@ -1267,7 +1386,7 @@ func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
hasAspect: true, width: 16, height: 9, hasAspect: true, width: 16, height: 9,
})) }))
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1315,7 +1434,7 @@ func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", playlist: "https://video.bsky.app/p.m3u8",
})) }))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any) video, ok := main["video"].(map[string]any)
if !ok { if !ok {
@@ -1332,7 +1451,7 @@ func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", playlist: "https://video.bsky.app/p.m3u8",
})) }))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any) video := main["video"].(map[string]any)
if video["name"] != "Video on Bluesky" { if video["name"] != "Video on Bluesky" {
@@ -1347,7 +1466,7 @@ func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip", playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
})) }))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any) video := main["video"].(map[string]any)
if video["description"] != "scenic clip" { if video["description"] != "scenic clip" {
@@ -1360,7 +1479,7 @@ func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "alt", playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
})) }))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any) video := main["video"].(map[string]any)
if _, present := video["width"]; present { if _, present := video["width"]; present {
@@ -1377,7 +1496,7 @@ func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg" thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x", pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideo(thumb)) withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present { if _, present := main["video"]; present {
t.Errorf("video without playlist should not produce VideoObject") t.Errorf("video without playlist should not produce VideoObject")
@@ -1396,7 +1515,7 @@ func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
alt: "should be dropped", alt: "should be dropped",
}), }),
withSelfLabel("porn")) withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present { if _, present := main["video"]; present {
t.Errorf("hidden-embed post should not emit video") t.Errorf("hidden-embed post should not emit video")
@@ -1411,7 +1530,7 @@ func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true, thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
hasAspect: true, width: 4, height: 3, hasAspect: true, width: 4, height: 3,
})) }))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any) video, ok := main["video"].(map[string]any)
if !ok { if !ok {
@@ -1438,7 +1557,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "bob's clip", thumbnail: thumb, playlist: playlist, alt: "bob's clip",
})) }))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any) video, ok := c["video"].(map[string]any)
@@ -1461,7 +1580,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) { func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present { if _, present := main["video"]; present {
t.Errorf("post without video should not include video field") t.Errorf("post without video should not include video field")
@@ -1505,7 +1624,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
playlist: playlist, alt: "scenic clip", playlist: playlist, alt: "scenic clip",
})) }))
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123" canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any) video, ok := main["video"].(map[string]any)
if !ok { if !ok {
@@ -1528,7 +1647,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
withVideoFull(videoEmbedOpts{ withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "bob's clip", playlist: playlist, alt: "bob's clip",
})) }))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any) main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any) c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any) video, ok := c["video"].(map[string]any)
+17
View File
@@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
} }
return false 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
}
+73
View File
@@ -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)
}
})
}
}
+7 -7
View File
@@ -52,7 +52,7 @@ func TestRenderBase_NoindexMeta(t *testing.T) {
func TestRenderPost_EmitsJSONLD(t *testing.T) { func TestRenderPost_EmitsJSONLD(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -81,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg" 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" 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)) 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{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123", "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -113,7 +113,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@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)) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false) thumbs := extractPostMedia(pv, false)
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{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123", "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -142,7 +142,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) { func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url. // Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") 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{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo", "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
@@ -208,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
// og:url and <link rel="canonical"> must emit the same URL. // og:url and <link rel="canonical"> must emit the same URL.
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) { func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") 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" canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
@@ -233,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
// video without a thumbnail dropped og:video entirely. // video without a thumbnail dropped og:video entirely.
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) { func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch") 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" videoURL := "https://video.bsky.app/v.m3u8"
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
@@ -286,7 +286,7 @@ func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
// flip of the noindex flag for indexable pages. // flip of the noindex flag for indexable pages.
func TestRenderPost_PublicNoNoindex(t *testing.T) { func TestRenderPost_PublicNoNoindex(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") 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) ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{ html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv, "postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123", "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
+55 -27
View File
@@ -620,23 +620,39 @@ func (srv *Server) WebPost(c echo.Context) error {
identifier := handleOrDID.Normalize().String() 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() req := c.Request()
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
// 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 || 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 // Always prefer the handle-form URL so JSON-LD `url` and
// <link rel="canonical"> match. Falls back to requestURI when the // <link rel="canonical"> match. Falls back to requestURI when the
// handle is unusable (template strips query/fragment). // handle is unusable (template strips query/fragment).
canonicalURL := bskyPostURL(pv.Handle, rkey.String()) canonicalURL := bskyPostURL(postView.Author.Handle, rkey.String())
if !unauthedViewingOkay { // 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 // Provide minimal OpenGraph data for auth-required posts
data["requestURI"] = requestURI data["requestURI"] = requestURI
if canonicalURL != "" { if canonicalURL != "" {
@@ -645,26 +661,13 @@ func (srv *Server) WebPost(c echo.Context) error {
data["requiresAuth"] = true data["requiresAuth"] = true
data["noindex"] = true data["noindex"] = true
data["nofollow"] = true data["nofollow"] = true
data["profileHandle"] = pv.Handle data["profileHandle"] = postView.Author.Handle
if pv.DisplayName != nil { if postView.Author.DisplayName != nil {
data["profileDisplayName"] = *pv.DisplayName data["profileDisplayName"] = *postView.Author.DisplayName
} }
return c.Render(http.StatusOK, "post.html", data) 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)
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 {
return c.Render(http.StatusOK, "post.html", data)
}
postView := threadView.Post
data["postView"] = postView data["postView"] = postView
data["requestURI"] = requestURI data["requestURI"] = requestURI
if canonicalURL != "" { if canonicalURL != "" {
@@ -694,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error {
if jsonldURL == "" { if jsonldURL == "" {
jsonldURL = requestURI 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 data["postJSONLD"] = jsonld
} else { } else {
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err) log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
+2 -2
View File
@@ -6,7 +6,7 @@ values.
Install dependencies in `/dev-env` Install dependencies in `/dev-env`
``` ```
cd dev-env && yarn cd dev-env && pnpm i
``` ```
## Using Maestro ## Using Maestro
@@ -27,7 +27,7 @@ adb reverse tcp:3000 tcp:3000
- In one tab, run `pnpm e2e:mock-server` - In one tab, run `pnpm e2e:mock-server`
- In a second tab, run `pnpm e2e:build` - 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 ## Using Flashlight for Performance Testing
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above 1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
+8 -16
View File
@@ -47,21 +47,6 @@
"EXPO_PUBLIC_ENV": "production" "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": { "testflight": {
"extends": "base", "extends": "base",
"ios": { "ios": {
@@ -78,7 +63,6 @@
}, },
"testflight-android": { "testflight-android": {
"extends": "base", "extends": "base",
"distribution": "internal",
"ios": { "ios": {
"autoIncrement": true, "autoIncrement": true,
"buildArtifactPaths": ["ios/build/*"] "buildArtifactPaths": ["ios/build/*"]
@@ -96,6 +80,14 @@
"production": { "production": {
"ios": { "ios": {
"ascAppId": "6444370199" "ascAppId": "6444370199"
},
"android": {
"track": "production"
}
},
"testflight-android": {
"android": {
"track": "internal"
} }
} }
} }
-27
View File
@@ -1836,11 +1836,6 @@
"count": 1 "count": 1
} }
}, },
"src/state/queries/list-memberships.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
}
},
"src/state/queries/list.ts": { "src/state/queries/list.ts": {
"@typescript-eslint/no-floating-promises": { "@typescript-eslint/no-floating-promises": {
"count": 7 "count": 7
@@ -2163,20 +2158,6 @@
"count": 2 "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": { "src/view/com/lists/MyLists.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
@@ -2208,9 +2189,6 @@
}, },
"@typescript-eslint/require-await": { "@typescript-eslint/require-await": {
"count": 1 "count": 1
},
"react-hooks/refs": {
"count": 1
} }
}, },
"src/view/com/notifications/NotificationFeed.tsx": { "src/view/com/notifications/NotificationFeed.tsx": {
@@ -2471,11 +2449,6 @@
"count": 2 "count": 2
} }
}, },
"src/view/screens/Storybook/Storybook.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/view/shell/Drawer.tsx": { "src/view/shell/Drawer.tsx": {
"@typescript-eslint/no-floating-promises": { "@typescript-eslint/no-floating-promises": {
"count": 2 "count": 2
+1 -1
View File
@@ -1,4 +1,4 @@
import {BottomSheetViewProps} from './BottomSheet.types' import {type BottomSheetViewProps} from './BottomSheet.types'
export function BottomSheet(_: BottomSheetViewProps) { export function BottomSheet(_: BottomSheetViewProps) {
throw new Error('BottomSheet is not available on web') throw new Error('BottomSheet is not available on web')
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types' import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule' import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
interface BackgroundNotificationPreferencesContext { interface BackgroundNotificationPreferencesContext {
@@ -1,6 +1,6 @@
import {requireNativeModule} from 'expo-modules-core' import {requireNativeModule} from 'expo-modules-core'
import {ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types' import {type ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
export const BackgroundNotificationHandler = export const BackgroundNotificationHandler =
requireNativeModule<ExpoBackgroundNotificationHandlerModule>( requireNativeModule<ExpoBackgroundNotificationHandlerModule>(
@@ -1,6 +1,6 @@
import { import {
BackgroundNotificationHandlerPreferences, type BackgroundNotificationHandlerPreferences,
ExpoBackgroundNotificationHandlerModule, type ExpoBackgroundNotificationHandlerModule,
} from './ExpoBackgroundNotificationHandler.types' } from './ExpoBackgroundNotificationHandler.types'
// Stub for web // Stub for web
@@ -2,7 +2,7 @@ import React from 'react'
import {requireNativeModule} from 'expo' import {requireNativeModule} from 'expo'
import {requireNativeViewManager} from 'expo-modules-core' import {requireNativeViewManager} from 'expo-modules-core'
import {GifViewProps} from './GifView.types' import {type GifViewProps} from './GifView.types'
const NativeModule = requireNativeModule('ExpoBlueskyGifView') const NativeModule = requireNativeModule('ExpoBlueskyGifView')
const NativeView: React.ComponentType< const NativeView: React.ComponentType<
@@ -1,7 +1,7 @@
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {requireNativeModule} from 'expo-modules-core' import {requireNativeModule} from 'expo-modules-core'
import {AudioCategory} from './types' import {type AudioCategory} from './types'
const NativeModule = requireNativeModule('ExpoPlatformInfo') const NativeModule = requireNativeModule('ExpoPlatformInfo')
@@ -1,5 +1,5 @@
import {NotImplementedError} from '../NotImplemented' import {NotImplementedError} from '../NotImplemented'
import {AudioCategory} from './types' import {type AudioCategory} from './types'
export function getIsReducedMotionEnabled(): boolean { export function getIsReducedMotionEnabled(): boolean {
throw new NotImplementedError() throw new NotImplementedError()
@@ -1,5 +1,5 @@
import {NotImplementedError} from '../NotImplemented' import {NotImplementedError} from '../NotImplemented'
import {AudioCategory} from './types' import {type AudioCategory} from './types'
export function getIsReducedMotionEnabled(): boolean { export function getIsReducedMotionEnabled(): boolean {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -1,6 +1,6 @@
import {requireNativeModule} from 'expo' import {requireNativeModule} from 'expo'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types' import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
export const NativeModule = requireNativeModule('ExpoBlueskyReferrer') export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
@@ -1,6 +1,6 @@
import {SharedPrefs} from '../../index' import {SharedPrefs} from '../../index'
import {NotImplementedError} from '../NotImplemented' import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types' import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> { export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError() throw new NotImplementedError()
@@ -1,5 +1,5 @@
import {NotImplementedError} from '../NotImplemented' import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types' import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> { export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError() throw new NotImplementedError()
@@ -1,7 +1,7 @@
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {NotImplementedError} from '../NotImplemented' import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types' import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> { export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError() throw new NotImplementedError()
@@ -1,5 +1,5 @@
import {NotImplementedError} from '../NotImplemented' import {NotImplementedError} from '../NotImplemented'
import {VisibilityViewProps} from './types' import {type VisibilityViewProps} from './types'
export async function updateActiveViewAsync() { export async function updateActiveViewAsync() {
throw new NotImplementedError() throw new NotImplementedError()
@@ -1,4 +1,4 @@
import React from 'react' import type React from 'react'
export interface VisibilityViewProps { export interface VisibilityViewProps {
children: React.ReactNode children: React.ReactNode
onChangeStatus: (isActive: boolean) => void onChangeStatus: (isActive: boolean) => void
+6 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.125.0", "version": "1.126.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=24.15.0"
@@ -90,10 +90,11 @@
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web", "generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web", "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
"icons:optimize": "svgo -f ./assets/icons", "icons:optimize": "svgo -f ./assets/icons",
"prettier": "prettier --check ." "prettier": "prettier --check .",
"update-actions": "pnpm dlx actions-up --min-age 7"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.12", "@atproto/api": "0.20.15",
"@atproto/syntax": "0.6.1", "@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
@@ -108,7 +109,6 @@
"@bsky.app/sift": "^0.3.9", "@bsky.app/sift": "^0.3.9",
"@bsky.app/tapper": "^0.6.1", "@bsky.app/tapper": "^0.6.1",
"@bsky.app/video": "0.3.6", "@bsky.app/video": "0.3.6",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.12.5", "@expo/html-elements": "^0.12.5",
@@ -204,6 +204,7 @@
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.shuffle": "^4.2.0", "lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mediabunny": "^1.25.3",
"multiformats": "^13.4.2", "multiformats": "^13.4.2",
"nanoid": "^5.0.5", "nanoid": "^5.0.5",
"normalize-url": "^8.0.0", "normalize-url": "^8.0.0",
@@ -331,7 +332,7 @@
"^await-lock$": "<rootDir>/node_modules/await-lock/build/AwaitLock.js" "^await-lock$": "<rootDir>/node_modules/await-lock/build/AwaitLock.js"
}, },
"transformIgnorePatterns": [ "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": [ "modulePathIgnorePatterns": [
"__tests__/.*/__mocks__", "__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),
[]
+28 -63
View File
@@ -215,7 +215,6 @@ overrides:
react-native-screens: 4.24.0 react-native-screens: 4.24.0
patchedDependencies: patchedDependencies:
'@discord/bottom-sheet@4.6.1': 253965341bfbc8e63630dfca511c5d0e0878900e5fe414b2278bd322fc215e2a
'@sentry/react-native@6.20.0': 1d48e4d5178f5684eb33262299e7eed283bf9601f79b9ae1af63a7f607d3483a '@sentry/react-native@6.20.0': 1d48e4d5178f5684eb33262299e7eed283bf9601f79b9ae1af63a7f607d3483a
expo-glass-effect@55.0.8: 6c9fa5b104e53b87df4e17b320acb3b94d12ac90c0101190045dd620681efff0 expo-glass-effect@55.0.8: 6c9fa5b104e53b87df4e17b320acb3b94d12ac90c0101190045dd620681efff0
expo-haptics@15.0.8: b33910ba2753c4eccdc885b449e6e33aa90b9cefa75ca8639762a26013141410 expo-haptics@15.0.8: b33910ba2753c4eccdc885b449e6e33aa90b9cefa75ca8639762a26013141410
@@ -242,8 +241,8 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: 0.20.12 specifier: 0.20.15
version: 0.20.12 version: 0.20.15
'@atproto/syntax': '@atproto/syntax':
specifier: 0.6.1 specifier: 0.6.1
version: 0.6.1 version: 0.6.1
@@ -286,9 +285,6 @@ importers:
'@bsky.app/video': '@bsky.app/video':
specifier: 0.3.6 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) 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': '@emoji-mart/data':
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1 version: 1.2.1
@@ -574,6 +570,9 @@ importers:
lodash.throttle: lodash.throttle:
specifier: ^4.1.1 specifier: ^4.1.1
version: 4.1.1 version: 4.1.1
mediabunny:
specifier: ^1.25.3
version: 1.49.0
multiformats: multiformats:
specifier: ^13.4.2 specifier: ^13.4.2
version: 13.4.2 version: 13.4.2
@@ -877,8 +876,8 @@ packages:
graphql: graphql:
optional: true optional: true
'@atproto/api@0.20.12': '@atproto/api@0.20.15':
resolution: {integrity: sha512-pNCrl/BSmkjlrVu0W5A9zkOVRIWdWAdajuHX/EyhDZFxu8HExsiQi6j46H5kv85GrNRXS/QtULE+ocSuMtEJfw==} resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.0': '@atproto/common-web@0.5.0':
@@ -1699,23 +1698,6 @@ packages:
resolution: {integrity: sha512-JRrHvitc0w+r/h6XXSOq917HxsjRoxPEtyNYSmylKirjiPNrRx84VKtqFlOIWIFCOZI5BoXg6hNpE0Zq7CwVBA==} resolution: {integrity: sha512-JRrHvitc0w+r/h6XXSOq917HxsjRoxPEtyNYSmylKirjiPNrRx84VKtqFlOIWIFCOZI5BoXg6hNpE0Zq7CwVBA==}
hasBin: true 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': '@discoveryjs/json-ext@0.5.7':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@@ -2107,12 +2089,6 @@ packages:
'@formatjs/intl-supportedvaluesof@2.3.7': '@formatjs/intl-supportedvaluesof@2.3.7':
resolution: {integrity: sha512-ZTXEKijV5H08z19odngZPsXlmGndfukHuVnjd9qyER77XSk/yJwDMiSjoJYY5C5WxFs2UN4s/EMtu75e6Tp3ug==} 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': '@growthbook/growthbook-react@1.6.5':
resolution: {integrity: sha512-afi/RUbwazVNKv2acn6wDQz4BJNRAEpwIuHfggQup2/aE5PLAxy3+95gjjRMgCcPR0Pf3sFmhYGvOmxLD0ZRbQ==} resolution: {integrity: sha512-afi/RUbwazVNKv2acn6wDQz4BJNRAEpwIuHfggQup2/aE5PLAxy3+95gjjRMgCcPR0Pf3sFmhYGvOmxLD0ZRbQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3367,13 +3343,6 @@ packages:
peerDependencies: peerDependencies:
webpack: '>=4.40.0' 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': '@sinclair/typebox@0.27.10':
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
@@ -3528,6 +3497,12 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} 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': '@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -6883,6 +6858,9 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mediabunny@1.49.0:
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
memfs@3.5.3: memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}
@@ -9494,7 +9472,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {} '@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.12': '@atproto/api@0.20.15':
dependencies: dependencies:
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1 '@atproto/lexicon': 0.7.1
@@ -10521,18 +10499,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- encoding - 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': {} '@discoveryjs/json-ext@0.5.7': {}
'@egjs/hammerjs@2.0.17': '@egjs/hammerjs@2.0.17':
@@ -11103,12 +11069,6 @@ snapshots:
dependencies: dependencies:
'@formatjs/fast-memoize': 3.1.5 '@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)': '@growthbook/growthbook-react@1.6.5(react@19.1.0)':
dependencies: dependencies:
'@growthbook/growthbook': 1.6.5 '@growthbook/growthbook': 1.6.5
@@ -12616,12 +12576,6 @@ snapshots:
- encoding - encoding
- supports-color - 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.27.10': {}
'@sinclair/typebox@0.34.49': {} '@sinclair/typebox@0.34.49': {}
@@ -12806,6 +12760,12 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.12.4 '@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': '@types/eslint-scope@3.7.7':
dependencies: dependencies:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
@@ -16722,6 +16682,11 @@ snapshots:
media-typer@0.3.0: {} 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: memfs@3.5.3:
dependencies: dependencies:
fs-monkey: 1.1.0 fs-monkey: 1.1.0
-1
View File
@@ -19,7 +19,6 @@ allowBuilds:
'esbuild': true 'esbuild': true
'unrs-resolver': true 'unrs-resolver': true
patchedDependencies: 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 '@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-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 'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
-3
View File
@@ -31,7 +31,6 @@ import {listenSessionDropped} from '#/state/events'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge' import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages' import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
@@ -241,7 +240,6 @@ function App() {
<PrefsStateProvider> <PrefsStateProvider>
<I18nProvider> <I18nProvider>
<ShellStateProvider> <ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider> <DialogStateProvider>
<LightboxStateProvider> <LightboxStateProvider>
<PortalProvider> <PortalProvider>
@@ -256,7 +254,6 @@ function App() {
</PortalProvider> </PortalProvider>
</LightboxStateProvider> </LightboxStateProvider>
</DialogStateProvider> </DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider> </ShellStateProvider>
</I18nProvider> </I18nProvider>
</PrefsStateProvider> </PrefsStateProvider>
-3
View File
@@ -24,7 +24,6 @@ import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events' import {listenSessionDropped} from '#/state/events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge' import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages' import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
@@ -220,7 +219,6 @@ function App() {
<PrefsStateProvider> <PrefsStateProvider>
<I18nProvider> <I18nProvider>
<ShellStateProvider> <ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider> <DialogStateProvider>
<LightboxStateProvider> <LightboxStateProvider>
<PortalProvider> <PortalProvider>
@@ -230,7 +228,6 @@ function App() {
</PortalProvider> </PortalProvider>
</LightboxStateProvider> </LightboxStateProvider>
</DialogStateProvider> </DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider> </ShellStateProvider>
</I18nProvider> </I18nProvider>
</PrefsStateProvider> </PrefsStateProvider>
@@ -399,7 +399,7 @@ function AccessSection() {
locationControl.open() locationControl.open()
})}> })}>
Tap here to update your location with GPS. Tap here to update your location with GPS.
</SimpleInlineLinkText>{' '} </SimpleInlineLinkText>
</Trans> </Trans>
</Admonition> </Admonition>
-1
View File
@@ -10,7 +10,6 @@ export enum Features {
ImportContactsSettingsDisable = 'import_contacts:settings:disable', ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable', LiveNowBetaDisable = 'live_now_beta:disable',
GroupChatsDisable = 'group_chats:disable', GroupChatsDisable = 'group_chats:disable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable', ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable', NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
+15
View File
@@ -5,6 +5,7 @@
import {type Platform} from 'react-native' import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type NotificationType} from '#/state/queries/notifications/types'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -53,6 +54,10 @@ export type Events = {
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home' context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home'
status: 'granted' | 'denied' | 'undetermined' status: 'granted' | 'denied' | 'undetermined'
} }
'notifications:bundleExpand': {
notificationType: NotificationType
authorCount: number
}
'state:background': { 'state:background': {
secondsActive: number secondsActive: number
} }
@@ -464,6 +469,7 @@ export type Events = {
| 'OnboardingSuggestedAccounts' | 'OnboardingSuggestedAccounts'
| 'FindContacts' | 'FindContacts'
| 'GroupChat' | 'GroupChat'
| 'NotificationExpandedProfileCard'
} }
'profile:followers:view': { 'profile:followers:view': {
contextProfileDid: string contextProfileDid: string
@@ -570,6 +576,7 @@ export type Events = {
| 'OnboardingSuggestedAccounts' | 'OnboardingSuggestedAccounts'
| 'FindContacts' | 'FindContacts'
| 'GroupChat' | 'GroupChat'
| 'NotificationExpandedProfileCard'
} }
'chat:create': { 'chat:create': {
logContext: logContext:
@@ -586,6 +593,14 @@ export type Events = {
| 'SendViaChatDialog' | 'SendViaChatDialog'
| 'ConvoSettings' | 'ConvoSettings'
} }
// Message replies
'chat:message:reply:send': {
convoId: string
isGroup: boolean
}
'chat:message:reply:tap': {
convoId: string
}
// Group chat adoption // Group chat adoption
'groupchat:create': { 'groupchat:create': {
+2 -1
View File
@@ -332,10 +332,10 @@ export function Composer({
contentPaddingStyle, contentPaddingStyle,
a.z_20, a.z_20,
{ {
color: 'transparent',
background: 'transparent', background: 'transparent',
}, },
web({ web({
color: 'transparent',
caretColor: textStyle.color ?? 'black', caretColor: textStyle.color ?? 'black',
overscrollBehavior: 'none', overscrollBehavior: 'none',
scrollbarWidth: 'thin', scrollbarWidth: 'thin',
@@ -346,6 +346,7 @@ export function Composer({
{...tapper.inputProps} {...tapper.inputProps}
{...sift.targetProps} {...sift.targetProps}
ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])} ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
rawValue={tapper.state.text}
onBlur={e => { onBlur={e => {
rest.onBlur?.(e) rest.onBlur?.(e)
setActiveFacet(null) setActiveFacet(null)
+16 -7
View File
@@ -241,6 +241,7 @@ export function Trigger({
contentLabel, contentLabel,
style, style,
onTap, onTap,
swipeGesture,
}: TriggerProps) { }: TriggerProps) {
const context = useContextMenuContext() const context = useContextMenuContext()
const playHaptic = useHaptics() const playHaptic = useHaptics()
@@ -364,12 +365,20 @@ export function Trigger({
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV]) }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
// Order matters here: doubleTapGesture must come before tapGesture. // Order matters here: doubleTapGesture must come before tapGesture.
const composedGestures = Gesture.Exclusive( const tapAndHoldGestures = Gesture.Exclusive(
doubleTapGesture, doubleTapGesture,
tapGesture, tapGesture,
pressAndHoldGesture, pressAndHoldGesture,
) )
// An optional swipe gesture (e.g. swipe-to-reply) races against the tap/hold
// group: whichever activates first wins and cancels the rest, so they're
// mutually exclusive. Race (not Exclusive) avoids a held-but-not-yet-moved
// swipe Pan blocking the long-press from firing.
const composedGestures = swipeGesture
? Gesture.Race(swipeGesture, tapAndHoldGestures)
: tapAndHoldGestures
const measurement = context.measurement || pendingMeasurement?.measurement const measurement = context.measurement || pendingMeasurement?.measurement
return ( return (
@@ -850,10 +859,11 @@ export function Item({
!unstyled && [ !unstyled && [
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.px_2xl, a.px_lg,
a.gap_sm,
a.rounded_md, a.rounded_md,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{gap: 6, minHeight: 44, paddingVertical: 10}, {minHeight: 44, paddingVertical: 10},
(focused || pressed || context.hoveredMenuItem === id) && (focused || pressed || context.hoveredMenuItem === id) &&
!rest.disabled && !rest.disabled &&
t.atoms.bg_contrast_50, t.atoms.bg_contrast_50,
@@ -882,8 +892,7 @@ export function ItemText({children, style}: ItemTextProps) {
style={[ style={[
a.flex_1, a.flex_1,
a.text_md, a.text_md,
a.font_semi_bold, a.font_medium,
t.atoms.text_contrast_high,
style, style,
destructive && {color: t.palette.negative_500}, destructive && {color: t.palette.negative_500},
disabled && t.atoms.text_contrast_low, disabled && t.atoms.text_contrast_low,
@@ -898,13 +907,13 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
const {disabled, destructive} = useContextMenuItemContext() const {disabled, destructive} = useContextMenuItemContext()
return ( return (
<Comp <Comp
size="lg" size="md"
fill={ fill={
disabled disabled
? t.atoms.text_contrast_low.color ? t.atoms.text_contrast_low.color
: destructive : destructive
? t.palette.negative_500 ? t.palette.negative_500
: t.atoms.text_contrast_medium.color : t.atoms.text.color
} }
/> />
) )
+9
View File
@@ -4,6 +4,7 @@ import {
type StyleProp, type StyleProp,
type ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {type GestureType} from 'react-native-gesture-handler'
import {type SharedValue} from 'react-native-reanimated' import {type SharedValue} from 'react-native-reanimated'
import type * as Dialog from '#/components/Dialog' import type * as Dialog from '#/components/Dialog'
@@ -94,6 +95,14 @@ export type TriggerProps = {
* @platform ios, android * @platform ios, android
*/ */
onTap?: () => void onTap?: () => void
/**
* An optional gesture (e.g. swipe-to-reply) composed into the same
* arbitration group as the tap and press-and-hold gestures via `Gesture.Race`,
* making it mutually exclusive with them - only one can win a given touch.
*
* @platform ios, android
*/
swipeGesture?: GestureType
} }
export type TriggerChildProps = export type TriggerChildProps =
| { | {
-5
View File
@@ -26,7 +26,6 @@ import {
isExternalUrl, isExternalUrl,
linkRequiresWarning, linkRequiresWarning,
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {useModalControls} from '#/state/modals'
import {useInAppBrowser} from '#/state/preferences/in-app-browser' import {useInAppBrowser} from '#/state/preferences/in-app-browser'
import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf' import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
import {Button, type ButtonProps} from '#/components/Button' import {Button, type ButtonProps} from '#/components/Button'
@@ -140,7 +139,6 @@ export function useLink({
} }
const isExternal = isExternalUrl(href) const isExternal = isExternalUrl(href)
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext() const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink() const openLink = useOpenLink()
const groupChatJoinIntent = useGroupChatJoinIntent() const groupChatJoinIntent = useGroupChatJoinIntent()
@@ -188,8 +186,6 @@ export function useLink({
) { ) {
void openLink(href) void openLink(href)
} else { } else {
closeModal() // close any active modals
const [screen, params] = router.matchPath(href) as [ const [screen, params] = router.matchPath(href) as [
screen: keyof AllNavigatorParams, screen: keyof AllNavigatorParams,
params?: RouteParams, params?: RouteParams,
@@ -242,7 +238,6 @@ export function useLink({
isExternal, isExternal,
href, href,
openLink, openLink,
closeModal,
action, action,
navigation, navigation,
overridePresentation, overridePresentation,
@@ -1,99 +0,0 @@
import {Linking, View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Sparkle_Stroke2_Corner0_Rounded as Sparkle} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
/**
* OTA-able fallback that ships to native builds which don't yet know how to
* render the new gallery embed (>4 images, Photos v2). Final copy and visual
* treatment pending design from Darrin/Danielle/Alex.
*
* Native-only per APP-2308 - web builds receive the new gallery support in
* the same release that adds it.
*/
export function GalleryFallbackEmbed({count}: {count?: number}) {
const t = useTheme()
const {t: l} = useLingui()
const bodyStyle = [
a.text_sm,
a.text_center,
a.leading_snug,
t.atoms.text_contrast_high,
]
return (
<View
style={[
a.mt_sm,
a.rounded_md,
a.border,
a.p_lg,
a.pb_2xl,
a.gap_sm,
a.align_center,
{
borderColor: t.palette.primary_200,
backgroundColor: t.palette.primary_25,
},
]}>
<Sparkle size="lg" fill={t.palette.primary_500} />
<Text style={[a.text_md, a.font_bold, a.text_center, t.atoms.text]}>
<Trans>Something new is here</Trans>
</Text>
{count ? (
<View>
<Text style={bodyStyle}>
{plural(count, {
one: 'This post has # photo.',
other: 'This post has # photos.',
})}
</Text>
{IS_NATIVE ? (
<Text style={bodyStyle}>
{plural(count, {
one: 'Update your app to see it.',
other: 'Update your app to see them all.',
})}
</Text>
) : (
<Text style={bodyStyle}>
{plural(count, {
one: 'Refresh the page to see it.',
other: 'Refresh the page to see them all.',
})}
</Text>
)}
</View>
) : IS_NATIVE ? (
<Text style={bodyStyle}>
<Trans>Update your app to see it.</Trans>
</Text>
) : (
<Text style={bodyStyle}>
<Trans>Refresh the page to see it.</Trans>
</Text>
)}
{IS_NATIVE && (
<Button
label={l`Update your app`}
size="small"
color="primary"
onPress={() => {
void Linking.openURL(BSKY_DOWNLOAD_URL)
}}
style={[a.mt_xs]}>
<ButtonText>
<Trans>Update app</Trans>
</ButtonText>
</Button>
)}
</View>
)
}
-1
View File
@@ -142,7 +142,6 @@ export function ImageEmbed({
onPress(0, [containerRef], [dims]) onPress(0, [containerRef], [dims])
} }
onPressIn={() => onPressIn(0)} onPressIn={() => onPressIn(0)}
hideBadge={rest.isWithinQuote}
/> />
</ImageContextMenu> </ImageContextMenu>
</View> </View>
+2 -6
View File
@@ -40,11 +40,7 @@ import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed' import {ImageEmbed} from './ImageEmbed'
import {ModeratedListEmbed} from './ListEmbed' import {ModeratedListEmbed} from './ListEmbed'
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder' import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
import { import {type CommonProps, type EmbedProps, PostEmbedViewContext} from './types'
type CommonProps,
type EmbedProps,
type PostEmbedViewContext,
} from './types'
import {VideoEmbed} from './VideoEmbed' import {VideoEmbed} from './VideoEmbed'
export {PostEmbedViewContext} from './types' export {PostEmbedViewContext} from './types'
@@ -356,7 +352,7 @@ export function QuoteEmbed({
return ( return (
<GalleryBleed> <GalleryBleed>
<View <View
style={[a.mt_sm]} style={[viewContext !== PostEmbedViewContext.ChatMessage && a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter} onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}> onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider <ContentHider
@@ -7,10 +7,8 @@ import {Trans} from '@lingui/react/macro'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAllListMembersQuery} from '#/state/queries/list-members'
import { import {
getMembership,
type ListMembersip,
useDangerousListMembershipsQuery,
useListMembershipAddMutation, useListMembershipAddMutation,
useListMembershipRemoveMutation, useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships' } from '#/state/queries/list-memberships'
@@ -61,7 +59,7 @@ function DialogInner({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {data: memberships} = useDangerousListMembershipsQuery() const {data: listMembers} = useAllListMembersQuery(list.uri)
const renderProfileCard = useCallback( const renderProfileCard = useCallback(
(item: ProfileItem) => { (item: ProfileItem) => {
@@ -69,13 +67,13 @@ function DialogInner({
<UserResult <UserResult
profile={item.profile} profile={item.profile}
onChange={onChange} onChange={onChange}
memberships={memberships} listMembers={listMembers}
list={list} list={list}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
/> />
) )
}, },
[onChange, memberships, list, moderationOpts], [onChange, listMembers, list, moderationOpts],
) )
return ( return (
@@ -86,16 +84,30 @@ function DialogInner({
) )
} }
/**
* Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record)
*/
function getMembership(
listMembers: AppBskyGraphDefs.ListItemView[] | undefined,
actorDid: string,
): string | false | undefined {
if (!listMembers) {
return undefined
}
const member = listMembers.find(item => item.subject.did === actorDid)
return member ? member.uri : false
}
function UserResult({ function UserResult({
profile, profile,
list, list,
memberships, listMembers,
onChange, onChange,
moderationOpts, moderationOpts,
}: { }: {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
list: AppBskyGraphDefs.ListView list: AppBskyGraphDefs.ListView
memberships: ListMembersip[] | undefined listMembers: AppBskyGraphDefs.ListItemView[] | undefined
onChange?: ( onChange?: (
type: 'add' | 'remove', type: 'add' | 'remove',
profile: bsky.profile.AnyProfileView, profile: bsky.profile.AnyProfileView,
@@ -104,8 +116,8 @@ function UserResult({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const membership = useMemo( const membership = useMemo(
() => getMembership(memberships, list.uri, profile.did), () => getMembership(listMembers, profile.did),
[memberships, list.uri, profile.did], [listMembers, profile.did],
) )
const {mutate: listMembershipAdd, isPending: isAddingPending} = const {mutate: listMembershipAdd, isPending: isAddingPending} =
useListMembershipAddMutation({ useListMembershipAddMutation({
@@ -0,0 +1,331 @@
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNetworkError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import {
type ListWithMembership,
removeListMembershipOptimistically,
updateListMembershipOptimistically,
useListsWithMembershipQuery,
} from '#/state/queries/lists-with-membership'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {BulletList_Stroke2_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export type UserAddRemoveListsDialogProps = {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView | undefined
onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
}
export function UserAddRemoveListsDialog({
control,
profile,
onAdd,
onRemove,
}: UserAddRemoveListsDialogProps) {
return (
<Dialog.Outer
control={control}
testID="userAddRemoveListsDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<ListsContent profile={profile} onAdd={onAdd} onRemove={onRemove} />
</Dialog.Outer>
)
}
function Empty() {
const t = useTheme()
return (
<View
style={[
a.gap_2xl,
platform({web: {paddingTop: 100}, native: {paddingTop: 64}}),
]}>
<View style={[a.gap_xs, a.align_center]}>
<ListIcon
size="xl"
style={{color: t.atoms.border_contrast_medium.borderColor}}
/>
<Text style={[a.text_center]}>
<Trans>You have no lists.</Trans>
</Text>
</View>
</View>
)
}
function ListsContent({
profile,
onAdd,
onRemove,
}: {
profile: bsky.profile.AnyProfileView | undefined
onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
}) {
const t = useTheme()
const control = Dialog.useDialogContext()
const {t: l} = useLingui()
const {gtMobile} = useBreakpoints()
const {
data,
isError,
isLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useListsWithMembershipQuery({actor: profile?.did})
const listItems = data?.pages.flatMap(page => page.listsWithMembership) || []
const onEndReached = async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
// Error handling is optional since this is just pagination
}
}
const renderItem = ({item}: {item: ListWithMembership}) =>
profile ? (
<ListItem
listWithMembership={item}
profile={profile}
onAdd={onAdd}
onRemove={onRemove}
/>
) : null
const onClose = () => {
control.close()
}
const listHeader = (
<View
style={[
a.justify_between,
a.align_center,
a.flex_row,
a.pb_lg,
t.atoms.bg,
gtMobile ? a.pt_2xl : a.pt_xl,
]}>
<Text style={[a.text_lg, a.font_semi_bold]}>
{profile ? (
<Trans>
Update {createSanitizedDisplayName(profile, true)} in Lists
</Trans>
) : (
<Trans>Update in Lists</Trans>
)}
</Text>
<Button
testID="doneBtn"
label={l`Close`}
onPress={onClose}
variant="ghost"
color="secondary"
size="small"
shape="round"
style={{margin: -8}}>
<ButtonIcon icon={XIcon} />
</Button>
</View>
)
return (
<Dialog.InnerFlatList
data={isLoading ? [{}] : listItems}
renderItem={
isLoading
? () => (
<View style={[a.align_center, a.py_2xl]}>
<Loader size="xl" />
</View>
)
: renderItem
}
keyExtractor={
isLoading
? () => 'lists_dialog_loader'
: (item: ListWithMembership) => item.list.uri
}
onEndReached={() => void onEndReached()}
onEndReachedThreshold={0.1}
stickyHeaderIndices={web([0])}
ListHeaderComponent={listHeader}
ListFooterComponent={
isFetchingNextPage ? (
<View style={[a.align_center, a.py_lg]}>
<Loader size="lg" />
</View>
) : null
}
ListEmptyComponent={!isLoading && data ? <Empty /> : null}
webInnerContentContainerStyle={[a.py_0]}
style={platform({
web: [a.px_2xl, a.pb_md],
native: [a.px_2xl, a.pt_lg],
})}
/>
)
}
function ListItem({
listWithMembership,
profile,
onAdd,
onRemove,
}: {
listWithMembership: ListWithMembership
profile: bsky.profile.AnyProfileView
onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
}) {
const {t: l} = useLingui()
const t = useTheme()
const queryClient = useQueryClient()
const {currentAccount} = useSession()
const list = listWithMembership.list
const listItem = listWithMembership.listItem
const isMember = !!listItem
const {mutate: addMembership, isPending: isPendingAdd} =
useListMembershipAddMutation({
subject: profile,
onSuccess: data => {
Toast.show(l`Added to list`)
onAdd?.(list.uri)
updateListMembershipOptimistically({
queryClient,
actor: profile.did,
listUri: list.uri,
membershipUri: data.uri,
subject: {
did: profile.did,
handle: profile.handle,
displayName: profile.displayName,
},
})
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to add to list', {safeMessage: err})
}
Toast.show(l`Failed to add to list`, {type: 'error'})
},
})
const {mutate: removeMembership, isPending: isPendingRemove} =
useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(l`Removed from list`)
onRemove?.(list.uri)
removeListMembershipOptimistically({
queryClient,
actor: profile.did,
listUri: list.uri,
})
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to remove from list', {safeMessage: err})
}
Toast.show(l`Failed to remove from list`, {type: 'error'})
},
})
const isPending = isPendingAdd || isPendingRemove
const handleToggleMembership = () => {
if (isPending) return
if (!isMember) {
addMembership({
listUri: list.uri,
actorDid: profile.did,
})
} else {
if (!listItem?.uri) {
logger.error('Cannot remove: missing membership URI')
return
}
removeMembership({
listUri: list.uri,
actorDid: profile.did,
membershipUri: listItem.uri,
})
}
}
return (
<View
testID={`toggleBtn-${list.name}`}
style={[a.flex_row, a.align_center, a.py_md, a.gap_md]}>
<UserAvatar size={40} avatar={list.avatar} type="list" />
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
numberOfLines={1}>
{sanitizeDisplayName(list.name)}
</Text>
<Text
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>User list by you</Trans>
) : (
<Trans>
User list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
{list.purpose === 'app.bsky.graph.defs#modlist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>Moderation list by you</Trans>
) : (
<Trans>
Moderation list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
</Text>
</View>
<Button
testID={`user-${profile.handle}-addBtn`}
label={isMember ? l`Remove` : l`Add`}
onPress={handleToggleMembership}
disabled={isPending}
size="tiny"
color={isMember ? 'secondary' : 'primary_subtle'}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText>
{isMember ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
</Button>
</View>
)
}
@@ -168,7 +168,7 @@ export function GroupChatsAnnouncement() {
a.font_medium, a.font_medium,
{color: t.palette.primary_500}, {color: t.palette.primary_500},
]}> ]}>
<Trans>New</Trans> <Trans context="nux-description">New</Trans>
</Text> </Text>
</View> </View>
<Text <Text
+9 -10
View File
@@ -2,8 +2,9 @@ import {View} from 'react-native'
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api' import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu' import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import {useMessageReplies} from '#/components/dms/MessageReplies'
import {SwipeToReply} from '#/components/dms/SwipeToReply'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export function ActionsWrapper({ export function ActionsWrapper({
@@ -20,23 +21,20 @@ export function ActionsWrapper({
children: React.ReactNode children: React.ReactNode
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const {setReply} = useMessageReplies()
return ( return (
<SwipeToReply isFromSelf={isFromSelf} onReply={() => setReply(message)}>
{swipeGesture => (
<MessageContextMenu <MessageContextMenu
message={message} message={message}
senderProfile={senderProfile} senderProfile={senderProfile}
moderationOpts={moderationOpts}> moderationOpts={moderationOpts}
swipeGesture={swipeGesture}>
{trigger => {trigger =>
// will always be true, since this file is platform split // will always be true, since this file is platform split
trigger.IS_NATIVE && ( trigger.IS_NATIVE && (
<View style={[a.flex_1, a.relative]}>
<View <View
style={[
{maxWidth: '80%'},
isFromSelf
? [a.self_end, a.align_end]
: [a.self_start, a.align_start],
]}
accessible={true} accessible={true}
accessibilityActions={[ accessibilityActions={[
{name: 'activate', label: l`Open message options`}, {name: 'activate', label: l`Open message options`},
@@ -44,9 +42,10 @@ export function ActionsWrapper({
onAccessibilityAction={() => trigger.control.open('full')}> onAccessibilityAction={() => trigger.control.open('full')}>
{children} {children}
</View> </View>
</View>
) )
} }
</MessageContextMenu> </MessageContextMenu>
)}
</SwipeToReply>
) )
} }
+9 -2
View File
@@ -15,7 +15,11 @@ import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker' import {EmojiReactionPicker} from './EmojiReactionPicker'
import {canReact, hasReachedReactionLimit} from './util' import {
canReact,
hasReachedReactionLimit,
MESSAGE_BUBBLE_MAX_WIDTH,
} from './util'
export function ActionsWrapper({ export function ActionsWrapper({
message, message,
@@ -163,7 +167,10 @@ export function ActionsWrapper({
</MessageContextMenu> </MessageContextMenu>
</View> </View>
<View <View
style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}> style={[
{maxWidth: MESSAGE_BUBBLE_MAX_WIDTH},
isFromSelf ? a.align_end : a.align_start,
]}>
{children} {children}
</View> </View>
</View> </View>
+19 -3
View File
@@ -1,5 +1,6 @@
import {memo, useCallback} from 'react' import {memo, useCallback} from 'react'
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {type GestureType} from 'react-native-gesture-handler'
import * as Clipboard from 'expo-clipboard' import * as Clipboard from 'expo-clipboard'
import { import {
type ChatBskyConvoDefs, type ChatBskyConvoDefs,
@@ -20,6 +21,8 @@ import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu' import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types' import {type TriggerProps} from '#/components/ContextMenu/types'
import {useMessageDialogs} from '#/components/dms/MessageOverlays' import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies'
import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language' import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
@@ -36,17 +39,24 @@ export let MessageContextMenu = ({
senderProfile, senderProfile,
moderationOpts, moderationOpts,
children, children,
swipeGesture,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
senderProfile?: bsky.profile.AnyProfileView senderProfile?: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts | undefined moderationOpts: ModerationOpts | undefined
children: TriggerProps['children'] children: TriggerProps['children']
/**
* Native only. A swipe gesture (swipe-to-reply) composed into the trigger's
* gesture group so it's mutually exclusive with the tap and long-press.
*/
swipeGesture?: GestureType
}): React.ReactNode => { }): React.ReactNode => {
const {t: l, i18n} = useLingui() const {t: l, i18n} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const convo = useConvoActive() const convo = useConvoActive()
const {openDeleteMessage, openReportMessage} = useMessageDialogs() const {openDeleteMessage, openReportMessage} = useMessageDialogs()
const {setReply} = useMessageReplies()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate() const translate = useGoogleTranslate()
@@ -141,7 +151,8 @@ export let MessageContextMenu = ({
label={l`Message options`} label={l`Message options`}
contentLabel={l`Message from @${ contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}> }: ${message.text}`}
swipeGesture={swipeGesture}>
{children} {children}
</ContextMenu.Trigger> </ContextMenu.Trigger>
@@ -151,6 +162,13 @@ export let MessageContextMenu = ({
timeStyle: 'short', timeStyle: 'short',
})}`} })}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
<ContextMenu.Item
testID="messageDropdownReplyBtn"
label={l`Reply`}
onPress={() => setReply(message)}>
<ContextMenu.ItemIcon icon={ReplyIcon} position="left" />
<ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText>
</ContextMenu.Item>
{message.text.length > 0 && ( {message.text.length > 0 && (
<> <>
<ContextMenu.Item <ContextMenu.Item
@@ -172,7 +190,6 @@ export let MessageContextMenu = ({
</> </>
)} )}
<ContextMenu.Item <ContextMenu.Item
destructive
testID="messageDropdownDeleteBtn" testID="messageDropdownDeleteBtn"
label={l`Delete message for me`} label={l`Delete message for me`}
onPress={() => openDeleteMessage(message)}> onPress={() => openDeleteMessage(message)}>
@@ -181,7 +198,6 @@ export let MessageContextMenu = ({
</ContextMenu.Item> </ContextMenu.Item>
{!isFromSelf && ( {!isFromSelf && (
<ContextMenu.Item <ContextMenu.Item
destructive
testID="messageDropdownReportBtn" testID="messageDropdownReportBtn"
label={l`Report message`} label={l`Report message`}
onPress={() => openReportMessage(message, senderProfile)}> onPress={() => openReportMessage(message, senderProfile)}>
+276 -13
View File
@@ -10,10 +10,13 @@ import {
import Animated, { import Animated, {
FadeIn, FadeIn,
FadeOut, FadeOut,
interpolateColor,
LayoutAnimationConfig, LayoutAnimationConfig,
LinearTransition, LinearTransition,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withDelay,
withSequence,
withTiming, withTiming,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
@@ -41,11 +44,13 @@ import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, platform, useTheme} from '#/alf' import {atoms as a, native, platform, useTheme, utils} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography' import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays' import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
@@ -58,33 +63,54 @@ import {groupReactions} from './ReactionsDialog'
import { import {
CLUSTERED_MESSAGE_THRESHOLD_MS, CLUSTERED_MESSAGE_THRESHOLD_MS,
filterBlockedReactions, filterBlockedReactions,
MESSAGE_BUBBLE_MAX_WIDTH,
MESSAGE_GAP_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS,
} from './util' } from './util'
const AVATAR_SIZE = 28 const AVATAR_SIZE = 28
const CLUSTERED_MESSAGE_GAP = 2 const CLUSTERED_MESSAGE_GAP = 2
const BORDER_RADIUS = 18 const BORDER_RADIUS = 20
const SQUARED_BORDER_RADIUS = 4 const SQUARED_BORDER_RADIUS = 4
const DISPLAY_NAME_INSET = 20 const DISPLAY_NAME_INSET = 20
function messageIsReply(
message:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null,
): boolean {
return (
ChatBskyConvoDefs.isMessageView(message) &&
(ChatBskyConvoDefs.isMessageView(message.replyTo) ||
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo))
)
}
function isWithinClusterBoundary({ function isWithinClusterBoundary({
isPending, isPending,
message,
adjacentMessage, adjacentMessage,
isFromSameSender, isFromSameSender,
currentSentAt,
direction, direction,
}: { }: {
isPending: boolean isPending: boolean
message: ChatBskyConvoDefs.MessageView
adjacentMessage: adjacentMessage:
| ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView | ChatBskyConvoDefs.DeletedMessageView
| null | null
isFromSameSender: boolean isFromSameSender: boolean
currentSentAt: string
direction: 'prev' | 'next' direction: 'prev' | 'next'
}): boolean { }): boolean {
// A reply always starts its own cluster, breaking grouping with the message
// above it. Looking back, that's a boundary if this message is a reply;
// looking forward, it's a boundary if the next message is a reply.
if (messageIsReply(direction === 'prev' ? message : adjacentMessage)) {
return true
}
if (!isFromSameSender) return true if (!isFromSameSender) return true
if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) { if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
const currentSentAt = message.sentAt
const thisDate = new Date(currentSentAt) const thisDate = new Date(currentSentAt)
const adjDate = new Date(adjacentMessage.sentAt) const adjDate = new Date(adjacentMessage.sentAt)
const diff = const diff =
@@ -128,6 +154,16 @@ let MessageItem = ({
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did)) const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
const {openReactions} = useMessageDialogs() const {openReactions} = useMessageDialogs()
const {scrollToMessage, highlightedMessage} = useMessageReplies()
// `replyTo` comes back hydrated as the referenced message (or a deleted-
// message tombstone). Narrow away the open-union fallback so we only render
// shapes we understand.
const replyTo =
ChatBskyConvoDefs.isMessageView(message.replyTo) ||
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo)
? message.replyTo
: undefined
const isPending = item.type === 'pending-message' const isPending = item.type === 'pending-message'
@@ -150,17 +186,17 @@ let MessageItem = ({
const isFirstInCluster = isWithinClusterBoundary({ const isFirstInCluster = isWithinClusterBoundary({
isPending, isPending,
message,
adjacentMessage: prevMessage, adjacentMessage: prevMessage,
isFromSameSender: isPrevFromSameSender, isFromSameSender: isPrevFromSameSender,
currentSentAt: message.sentAt,
direction: 'prev', direction: 'prev',
}) })
const isLastInCluster = isWithinClusterBoundary({ const isLastInCluster = isWithinClusterBoundary({
isPending, isPending,
message,
adjacentMessage: nextMessage, adjacentMessage: nextMessage,
isFromSameSender: isNextFromSameSender, isFromSameSender: isNextFromSameSender,
currentSentAt: message.sentAt,
direction: 'next', direction: 'next',
}) })
@@ -198,6 +234,15 @@ let MessageItem = ({
const pendingColor = t.palette.primary_300 const pendingColor = t.palette.primary_300
const bubbleColor = isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50
const highlightColor = isFromSelf
? t.palette.primary_300
: t.palette.primary_100
const rt = new RichTextAPI({text: message.text, facets: message.facets}) const rt = new RichTextAPI({text: message.text, facets: message.facets})
const hasEmbed = const hasEmbed =
@@ -226,6 +271,30 @@ let MessageItem = ({
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300})) topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
}, [targetTopRadius, topRadiusSV]) }, [targetTopRadius, topRadiusSV])
// Flash the message background when it's been scrolled to (e.g. by tapping a
// reply that quotes it), so it's easy to spot. Keyed on the highlight `key`
// so re-tapping the same message re-triggers the flash.
const highlightSV = useSharedValue(0)
const isHighlighted = highlightedMessage?.id === message.id
const highlightKey = isHighlighted ? highlightedMessage.key : null
useEffect(() => {
if (highlightKey === null) return
highlightSV.set(
withSequence(
withTiming(1, {duration: 150}),
withDelay(400, withTiming(0, {duration: 450})),
),
)
}, [highlightKey, highlightSV])
const highlightStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
highlightSV.get(),
[0, 1],
[bubbleColor, highlightColor],
),
}))
const borderRadiusStyle = useAnimatedStyle(() => const borderRadiusStyle = useAnimatedStyle(() =>
isFromSelf isFromSelf
? { ? {
@@ -391,7 +460,16 @@ let MessageItem = ({
a.flex_grow, a.flex_grow,
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE}, !isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
]}> ]}>
{displayName && showDisplayName ? ( {replyTo ? (
<ReplyCaption
replyTo={replyTo}
isFromSelf={isFromSelf}
isGroupChat={isGroupChat}
replierDisplayName={displayName}
relatedProfiles={relatedProfiles}
onPress={() => scrollToMessage(replyTo.id)}
/>
) : displayName && showDisplayName ? (
<Text <Text
style={[ style={[
a.text_xs, a.text_xs,
@@ -422,6 +500,7 @@ let MessageItem = ({
squaredBottomCorner || hasEmbedAndText squaredBottomCorner || hasEmbedAndText
} }
squaredTopCorner={squaredTopCorner} squaredTopCorner={squaredTopCorner}
highlightSV={highlightSV}
/> />
)} )}
{ChatBskyEmbedJoinLink.isView(message.embed) && ( {ChatBskyEmbedJoinLink.isView(message.embed) && (
@@ -433,6 +512,7 @@ let MessageItem = ({
squaredBottomCorner || hasEmbedAndText squaredBottomCorner || hasEmbedAndText
} }
squaredTopCorner={squaredTopCorner} squaredTopCorner={squaredTopCorner}
highlightSV={highlightSV}
/> />
)} )}
{rt.text.length > 0 && ( {rt.text.length > 0 && (
@@ -444,20 +524,25 @@ let MessageItem = ({
a.rounded_xl, a.rounded_xl,
a.py_sm, a.py_sm,
a.px_md, a.px_md,
a.max_w_full,
{ {
marginTop: hasEmbedAndText marginTop: hasEmbedAndText
? CLUSTERED_MESSAGE_GAP ? CLUSTERED_MESSAGE_GAP
: 0, : 0,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
}, },
isFromSelf ? a.self_end : a.self_start, isFromSelf ? a.self_end : a.self_start,
borderRadiusStyle, borderRadiusStyle,
highlightStyle,
], ],
]}> ]}>
{replyTo && !isOnlyEmoji(message.text) ? (
<ReplyQuote
replyTo={replyTo}
isFromSelf={isFromSelf}
relatedProfiles={relatedProfiles}
onPress={() => scrollToMessage(replyTo.id)}
/>
) : null}
<RichText <RichText
value={rt} value={rt}
style={[ style={[
@@ -567,7 +652,7 @@ function BlockedPlaceholder({
return ( return (
<> <>
<Button <Button
style={[{maxWidth: '80%'}, a.self_start]} style={[{maxWidth: MESSAGE_BUBBLE_MAX_WIDTH}, a.self_start]}
label={ label={
profile.viewer?.blocking profile.viewer?.blocking
? l`This message is hidden because you are blocking this user.` ? l`This message is hidden because you are blocking this user.`
@@ -648,3 +733,181 @@ function BlockedPlaceholder({
</> </>
) )
} }
/**
* The "↪ X replied to Y" caption rendered above a reply message, in place of
* the display name. `X` is the person sending the reply (self -> "you"), `Y` is
* the original sender. Tapping it scrolls to the original (if loaded).
*
* Aligns with the sender's display name for others (left), or with the message
* bubble for self (right).
*/
function ReplyCaption({
replyTo,
isFromSelf,
isGroupChat,
replierDisplayName,
relatedProfiles,
onPress,
}: {
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
isFromSelf: boolean
isGroupChat: boolean
replierDisplayName: string | null
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
onPress: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did
const originalProfile = relatedProfiles.get(replyTo.sender.did)
const originalName = originalSenderIsSelf
? null
: originalProfile
? createSanitizedDisplayName(originalProfile)
: null
return (
<Button
label={l`Scroll to the message this is replying to`}
onPress={onPress}
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_2xs,
a.pb_2xs,
a.pt_xs,
isFromSelf
? [a.justify_end, a.pr_md]
: [
a.justify_start,
isGroupChat ? {paddingLeft: DISPLAY_NAME_INSET} : a.pl_md,
],
]}>
<ArrowCornerDownRightIcon
size="xs"
style={t.atoms.text_contrast_medium}
/>
<Text
style={[a.text_xs, a.flex_shrink, t.atoms.text_contrast_medium]}
numberOfLines={1}
emoji>
{isFromSelf ? (
originalSenderIsSelf ? (
<Trans>You replied to yourself</Trans>
) : originalName ? (
<Trans>You replied to {originalName}</Trans>
) : (
<Trans>You replied</Trans>
)
) : originalSenderIsSelf ? (
<Trans>{replierDisplayName} replied to you</Trans>
) : originalName ? (
<Trans>
{replierDisplayName} replied to {originalName}
</Trans>
) : (
<Trans>{replierDisplayName} replied</Trans>
)}
</Text>
</Button>
)
}
/**
* The nested quote of the original message, rendered at the top of a reply
* bubble. Tapping it scrolls to the original (if loaded).
*/
function ReplyQuote({
replyTo,
isFromSelf,
relatedProfiles,
onPress,
}: {
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
isFromSelf: boolean
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
onPress: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const senderProfile = useMaybeProfileShadow(
relatedProfiles.get(replyTo.sender.did),
)
// Hide the quoted content if we block, or are blocked by, the original
// sender - mirroring how the message bubble itself is hidden.
const isBlocked = senderProfile ? isBlockedOrBlocking(senderProfile) : false
const senderName =
senderProfile && !isBlocked
? createSanitizedDisplayName(senderProfile)
: null
const tintColor = isFromSelf ? t.palette.white : t.atoms.text.color
const subtleColor = isFromSelf
? t.palette.white
: t.atoms.text_contrast_high.color
const borderColor = isFromSelf
? utils.alpha(t.palette.white, 0.5)
: t.atoms.border_contrast_high.borderColor
let text: string
let subtle = false
if (isBlocked) {
text = l`Blocked message hidden`
subtle = true
} else if (ChatBskyConvoDefs.isMessageView(replyTo)) {
text = replyTo.text
if (!text.trim()) {
subtle = true
if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) {
text = l`(chat invite link)`
} else if (AppBskyEmbedRecord.isView(replyTo.embed)) {
text = l`(contains embedded content)`
} else {
text = l`No text`
}
}
} else {
text = l`Deleted message`
subtle = true
}
return (
<Button
label={
senderName
? l`Replied-to message from ${senderName}, tap to scroll to it`
: l`Replied-to message, tap to scroll to it`
}
onPress={onPress}
style={[
a.mb_xs,
a.rounded_md,
a.p_sm,
a.flex_col,
a.align_start,
a.border,
{borderColor, marginHorizontal: -4},
]}>
{senderName ? (
<Text style={[a.text_xs, {color: subtleColor}]} emoji numberOfLines={1}>
{senderName}
</Text>
) : null}
<Text
style={[
a.text_sm,
{color: subtle ? subtleColor : tintColor},
subtle && a.italic,
]}
emoji
numberOfLines={2}>
{text}
</Text>
</Button>
)
}
+42 -29
View File
@@ -1,5 +1,10 @@
import {memo} from 'react' import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native' import {useWindowDimensions, View} from 'react-native'
import Animated, {
interpolateColor,
type SharedValue,
useAnimatedStyle,
} from 'react-native-reanimated'
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api' import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
import {atoms as a, native, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
@@ -15,20 +20,53 @@ let MessageItemEmbed = ({
isGroupChat, isGroupChat,
squaredTopCorner, squaredTopCorner,
squaredBottomCorner, squaredBottomCorner,
highlightSV,
}: { }: {
embed: $Typed<AppBskyEmbedRecord.View> embed: $Typed<AppBskyEmbedRecord.View>
isFromSelf: boolean isFromSelf: boolean
isGroupChat: boolean isGroupChat: boolean
squaredTopCorner: boolean squaredTopCorner: boolean
squaredBottomCorner: boolean squaredBottomCorner: boolean
highlightSV: SharedValue<number>
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const screen = useWindowDimensions() const screen = useWindowDimensions()
const restingColor = isFromSelf ? t.palette.primary_50 : t.palette.contrast_50
const highlightColor = isFromSelf
? t.palette.primary_300
: t.palette.primary_100
const highlightStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
highlightSV.get(),
[0, 1],
[restingColor, highlightColor],
),
}))
const radiiStyle = isFromSelf
? {
borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopRightRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
}
: {
borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopLeftRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
}
return ( return (
<MessageContextProvider> <MessageContextProvider>
<View <View
style={[ style={[
isFromSelf ? a.self_end : a.self_start,
!isFromSelf && isGroupChat && a.ml_sm, !isFromSelf && isGroupChat && a.ml_sm,
native({ native({
flexBasis: 0, flexBasis: 0,
@@ -39,41 +77,16 @@ let MessageItemEmbed = ({
minWidth: 280, minWidth: 280,
maxWidth: 360, maxWidth: 360,
}), }),
// Cancel out the embed's internal a.mt_sm so the container's
// CLUSTERED_MESSAGE_GAP (2px) is the only spacing applied
{marginTop: -a.mt_sm.marginTop},
]}> ]}>
<View> <Animated.View
style={[a.rounded_xl, a.overflow_hidden, radiiStyle, highlightStyle]}>
<Embed <Embed
embed={embed} embed={embed}
allowNestedQuotes allowNestedQuotes
viewContext={PostEmbedViewContext.ChatMessage} viewContext={PostEmbedViewContext.ChatMessage}
style={[ style={[a.rounded_xl, a.overflow_hidden, a.border_0, radiiStyle]}
a.rounded_xl,
a.overflow_hidden,
a.border_0,
isFromSelf
? {
backgroundColor: t.palette.primary_50,
borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopRightRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
}
: {
backgroundColor: t.palette.contrast_50,
borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopLeftRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
},
]}
/> />
</View> </Animated.View>
</View> </View>
</MessageContextProvider> </MessageContextProvider>
) )
+23 -4
View File
@@ -1,5 +1,10 @@
import {memo} from 'react' import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native' import {useWindowDimensions, View} from 'react-native'
import Animated, {
interpolateColor,
type SharedValue,
useAnimatedStyle,
} from 'react-native-reanimated'
import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api' import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
@@ -17,17 +22,31 @@ let MessageItemInviteEmbed = ({
isGroupChat, isGroupChat,
squaredTopCorner, squaredTopCorner,
squaredBottomCorner, squaredBottomCorner,
highlightSV,
}: { }: {
embed: $Typed<ChatBskyEmbedJoinLink.View> embed: $Typed<ChatBskyEmbedJoinLink.View>
isFromSelf: boolean isFromSelf: boolean
isGroupChat: boolean isGroupChat: boolean
squaredTopCorner: boolean squaredTopCorner: boolean
squaredBottomCorner: boolean squaredBottomCorner: boolean
highlightSV: SharedValue<number>
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const screen = useWindowDimensions() const screen = useWindowDimensions()
const convo = useConvoActive() const convo = useConvoActive()
const restingColor = isFromSelf ? t.palette.primary_50 : t.palette.contrast_50
const highlightColor = isFromSelf
? t.palette.primary_300
: t.palette.primary_100
const highlightStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
highlightSV.get(),
[0, 1],
[restingColor, highlightColor],
),
}))
const code = isKnownJoinLinkPreview(embed.joinLinkPreview) const code = isKnownJoinLinkPreview(embed.joinLinkPreview)
? embed.joinLinkPreview.code ? embed.joinLinkPreview.code
: undefined : undefined
@@ -37,6 +56,7 @@ let MessageItemInviteEmbed = ({
<MessageContextProvider> <MessageContextProvider>
<View <View
style={[ style={[
isFromSelf ? a.self_end : a.self_start,
!isFromSelf && isGroupChat && a.ml_sm, !isFromSelf && isGroupChat && a.ml_sm,
native({ native({
flexBasis: 0, flexBasis: 0,
@@ -48,14 +68,13 @@ let MessageItemInviteEmbed = ({
maxWidth: 360, maxWidth: 360,
}), }),
]}> ]}>
<View <Animated.View
style={[ style={[
a.p_md, a.p_md,
a.gap_md, a.gap_md,
a.overflow_hidden, a.overflow_hidden,
isFromSelf isFromSelf
? { ? {
backgroundColor: t.palette.primary_50,
borderBottomRightRadius: squaredBottomCorner borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS ? SQUARED_BORDER_RADIUS
: BORDER_RADIUS, : BORDER_RADIUS,
@@ -66,7 +85,6 @@ let MessageItemInviteEmbed = ({
borderTopLeftRadius: BORDER_RADIUS, borderTopLeftRadius: BORDER_RADIUS,
} }
: { : {
backgroundColor: t.palette.contrast_50,
borderBottomLeftRadius: squaredBottomCorner borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS ? SQUARED_BORDER_RADIUS
: BORDER_RADIUS, : BORDER_RADIUS,
@@ -76,6 +94,7 @@ let MessageItemInviteEmbed = ({
borderBottomRightRadius: BORDER_RADIUS, borderBottomRightRadius: BORDER_RADIUS,
borderTopRightRadius: BORDER_RADIUS, borderTopRightRadius: BORDER_RADIUS,
}, },
highlightStyle,
]}> ]}>
<ChatInvite.Root <ChatInvite.Root
code={code} code={code}
@@ -84,7 +103,7 @@ let MessageItemInviteEmbed = ({
hasFixedHeight={false}> hasFixedHeight={false}>
<MessageItemInviteEmbedBody /> <MessageItemInviteEmbedBody />
</ChatInvite.Root> </ChatInvite.Root>
</View> </Animated.View>
</View> </View>
</MessageContextProvider> </MessageContextProvider>
) )
+124
View File
@@ -0,0 +1,124 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {type ChatBskyConvoDefs} from '@atproto/api'
/**
* How long a message stays highlighted after scrolling to it, before the flash
* fades out.
*/
export const MESSAGE_HIGHLIGHT_DURATION_MS = 1500
type HighlightedMessage = {
id: string
/**
* Bumped on every highlight so that re-tapping the same reply re-triggers the
* flash even while the previous highlight is still active.
*/
key: number
}
type MessageRepliesContextType = {
/**
* The message currently staged for reply in the composer, or null.
*/
replyTo: ChatBskyConvoDefs.MessageView | null
setReply: (message: ChatBskyConvoDefs.MessageView) => void
clearReply: () => void
/**
* Scroll the list to a message, if it's currently loaded, and flash it. No-op
* otherwise.
*/
scrollToMessage: (messageId: string) => void
/**
* The message to flash, or null. Consumers compare against their own id.
*/
highlightedMessage: HighlightedMessage | null
}
const Context = createContext<MessageRepliesContextType | null>(null)
export function useMessageReplies() {
const ctx = useContext(Context)
if (!ctx) {
throw new Error(
'useMessageReplies must be used within a MessageRepliesProvider',
)
}
return ctx
}
export function MessageRepliesProvider({
children,
scrollToMessage: scrollToMessageRaw,
}: {
children: React.ReactNode
/**
* Performs the actual scroll. Returns true if the message was found and
* scrolled to, false if it isn't currently loaded (so we know whether to
* flash it).
*/
scrollToMessage: (messageId: string) => boolean
}) {
const [replyTo, setReplyTo] = useState<ChatBskyConvoDefs.MessageView | null>(
null,
)
const [highlightedMessage, setHighlightedMessage] =
useState<HighlightedMessage | null>(null)
const highlightKey = useRef(0)
const clearHighlightTimeout = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const setReply = useCallback((message: ChatBskyConvoDefs.MessageView) => {
setReplyTo(message)
}, [])
const clearReply = useCallback(() => {
setReplyTo(null)
}, [])
const scrollToMessage = useCallback(
(messageId: string) => {
const didScroll = scrollToMessageRaw(messageId)
if (!didScroll) return
highlightKey.current += 1
setHighlightedMessage({id: messageId, key: highlightKey.current})
if (clearHighlightTimeout.current) {
clearTimeout(clearHighlightTimeout.current)
}
clearHighlightTimeout.current = setTimeout(() => {
setHighlightedMessage(null)
}, MESSAGE_HIGHLIGHT_DURATION_MS)
},
[scrollToMessageRaw],
)
useEffect(() => {
return () => {
if (clearHighlightTimeout.current) {
clearTimeout(clearHighlightTimeout.current)
}
}
}, [])
const ctx = useMemo<MessageRepliesContextType>(
() => ({
replyTo,
setReply,
clearReply,
scrollToMessage,
highlightedMessage,
}),
[replyTo, setReply, clearReply, scrollToMessage, highlightedMessage],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
+196
View File
@@ -0,0 +1,196 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {Gesture, type GestureType} from 'react-native-gesture-handler'
import Animated, {
clamp,
interpolate,
runOnJS,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSequence,
withTiming,
} from 'react-native-reanimated'
import {useHaptics} from '#/lib/haptics'
import {atoms as a, tokens, useTheme} from '#/alf'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
import {MESSAGE_BUBBLE_MAX_WIDTH} from './util'
// Distance the bubble must travel before releasing fires a reply.
const ACTIVATION_THRESHOLD = 56
// Past the activation point the bubble keeps moving but with exponentially
// increasing resistance, asymptotically approaching this much extra travel.
const MAX_OVERSHOOT = 32
const ICON_DIAMETER = 32
// Absurdly high value so the gesture doesn't arm on the vertical axis and
// hijack list scrolling. reanimated/RNGH don't offer clean per-axis disabling.
const EFFECTIVELY_DISABLED_OFFSET = 200
// 1:1 up to `threshold`, then exponentially diminishing travel: the marginal
// movement decays the further you pull, easing toward a soft cap of
// `threshold + MAX_OVERSHOOT` rather than hitting a hard wall.
function applyResistance(value: number) {
'worklet'
const abs = Math.abs(value)
if (abs <= ACTIVATION_THRESHOLD) return value
const sign = value < 0 ? -1 : 1
const excess = abs - ACTIVATION_THRESHOLD
return (
sign *
(ACTIVATION_THRESHOLD +
MAX_OVERSHOOT * (1 - Math.exp(-excess / MAX_OVERSHOOT)))
)
}
/**
* Swipe a message bubble inward to reply, Signal/WhatsApp style. Self messages
* (right-aligned) swipe left; others' messages (left-aligned) swipe right. A
* reply icon sits stationary in the gutter on the bubble's resting edge and is
* revealed (fade + scale) as the bubble slides away from it. Crossing the
* activation threshold fires a haptic; releasing past it triggers `onReply`.
*
* The pan gesture is built here but handed to `children` via a render prop so
* the consumer can compose it into the message's context-menu gestures with
* `Gesture.Exclusive`. Sharing one arbitration group is what makes the swipe
* and the long-press mutually exclusive - only one can ever win the touch.
*/
export function SwipeToReply({
isFromSelf,
onReply,
enabled = true,
children,
}: {
isFromSelf: boolean
onReply: () => void
enabled?: boolean
children: (swipeGesture: GestureType) => React.ReactNode
}) {
const t = useTheme()
const playHaptic = useHaptics()
const isReducedMotion = useReducedMotion()
const transX = useSharedValue(0)
const hit = useSharedValue(false)
const iconScale = useSharedValue(1)
const swipeGesture = useMemo(() => {
const runPop = () => {
'worklet'
if (isReducedMotion) return
iconScale.set(() =>
withSequence(
withTiming(1.2, {duration: 175}),
withTiming(1, {duration: 100}),
),
)
}
return (
Gesture.Pan()
.enabled(enabled)
// Arm only on the inward axis; the outward direction is effectively
// disabled so the bubble can't be dragged off its own edge.
.activeOffsetX(
isFromSelf
? [-10, EFFECTIVELY_DISABLED_OFFSET]
: [-EFFECTIVELY_DISABLED_OFFSET, 10],
)
.activeOffsetY([
-EFFECTIVELY_DISABLED_OFFSET,
EFFECTIVELY_DISABLED_OFFSET,
])
.onChange(e => {
'worklet'
const dir = isFromSelf
? Math.min(e.translationX, 0)
: Math.max(e.translationX, 0)
transX.set(applyResistance(dir))
const pastThreshold = Math.abs(transX.get()) >= ACTIVATION_THRESHOLD
if (pastThreshold && !hit.get()) {
hit.set(true)
runPop()
runOnJS(playHaptic)('Medium')
} else if (!pastThreshold && hit.get()) {
hit.set(false)
}
})
.onEnd(() => {
'worklet'
// Only a clean end (finger lifted past threshold) triggers the reply.
if (hit.get()) {
runOnJS(onReply)()
}
})
.onFinalize(() => {
'worklet'
// Runs on both end and cancellation, so the bubble always animates
// home even if the gesture is interrupted mid-swipe.
transX.set(withTiming(0, {duration: 200}))
hit.set(false)
})
)
}, [
isFromSelf,
enabled,
onReply,
playHaptic,
isReducedMotion,
transX,
hit,
iconScale,
])
const contentStyle = useAnimatedStyle(() => ({
transform: [{translateX: transX.get()}],
}))
const iconStyle = useAnimatedStyle(() => {
const progress = clamp(Math.abs(transX.get()) / ACTIVATION_THRESHOLD, 0, 1)
return {
opacity: progress,
transform: [
{scale: iconScale.get() * interpolate(progress, [0, 1], [0.5, 1])},
],
}
})
return (
<View style={[a.flex_1, a.relative]}>
<Animated.View
pointerEvents="none"
style={[
a.absolute,
a.justify_center,
{top: 0, bottom: 0},
// Centred roughly half the activation threshold in from the bubble's
// resting edge, so it lands in the gap the bubble reveals.
isFromSelf ? {right: tokens.space.md} : {left: tokens.space.md},
iconStyle,
]}>
<View
style={[
a.justify_center,
a.align_center,
a.rounded_full,
t.atoms.bg_contrast_50,
{width: ICON_DIAMETER, height: ICON_DIAMETER},
]}>
<ReplyIcon size="sm" style={t.atoms.text_contrast_medium} />
</View>
</Animated.View>
{/* Sized + aligned to the bubble so the consumer's GestureDetector (the
context menu's, which composes `swipeGesture`) covers only the bubble,
leaving the gutter free for the back/scroll gestures. */}
<Animated.View
style={[
{maxWidth: MESSAGE_BUBBLE_MAX_WIDTH},
isFromSelf ? a.self_end : a.self_start,
contentStyle,
]}>
{children(swipeGesture)}
</Animated.View>
</View>
)
}
+6
View File
@@ -11,12 +11,18 @@ import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow' import {type Shadow} from '#/state/cache/profile-shadow'
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types' import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
import {platform} from '#/alf'
import {type ReportSubject} from '#/components/moderation/ReportDialog/types' import {type ReportSubject} from '#/components/moderation/ReportDialog/types'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
export const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 export const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
export const MESSAGE_BUBBLE_MAX_WIDTH = platform({
web: '80%' as const,
default: '85%' as const,
})
export function canBeMessaged(profile: bsky.profile.AnyProfileView) { export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
switch (profile.associated?.chat?.allowIncoming) { switch (profile.associated?.chat?.allowIncoming) {
case 'none': case 'none':
+29 -1
View File
@@ -1,4 +1,4 @@
import {useMemo, useRef, useState} from 'react' import {useEffect, useMemo, useRef, useState} from 'react'
import { import {
TextInput, TextInput,
type TextInputContentSizeChangeEvent, type TextInputContentSizeChangeEvent,
@@ -16,6 +16,17 @@ export type AutosizedTextareaProps = Omit<TextInputProps, 'multiline'> & {
minRows?: number minRows?: number
maxRows?: number maxRows?: number
onUpdateHeight?: (height: number) => void onUpdateHeight?: (height: number) => void
/**
* In some cases, like on native, we may not pass in an actual `value` prop.
* This prop allows Android to know if the field was cleared and thereby
* reset its height. This is a small hack required because we need to
* explicitly set the `{height: nativeHeight}` on Android.
*
* If you notice height calcuation issues after clearing the field on
* Android, check if this value is being populated and cleared properly in
* the parent component.
*/
rawValue?: string
} }
export function AutosizedTextarea({ export function AutosizedTextarea({
@@ -24,6 +35,7 @@ export function AutosizedTextarea({
minRows = 1, minRows = 1,
maxRows, maxRows,
onUpdateHeight, onUpdateHeight,
rawValue,
onChangeText: onChangeTextOuter, onChangeText: onChangeTextOuter,
onContentSizeChange: onContentSizeChangeOuter, onContentSizeChange: onContentSizeChangeOuter,
@@ -124,6 +136,22 @@ export function AutosizedTextarea({
onContentSizeChangeOuter?.(e) onContentSizeChangeOuter?.(e)
} }
/*
* Manual height clearing required for Android because we're forced to set
* `{height: nativeHeight}` on Android, and even though `onContentSizeChange`
* fires, the height matches the existing height and we aren't able to reset.
*/
const prevRawValue = useRef(rawValue || '')
useEffect(() => {
if (!IS_ANDROID) return // everything else is fine
if (rawValue === undefined) return // uncontrolled
if (prevRawValue.current?.length && rawValue === '') {
setNativeHeight(minInputHeight)
onUpdateHeight?.(minInputHeight)
}
prevRawValue.current = rawValue
}, [rawValue, minInputHeight])
return ( return (
<TextInput <TextInput
multiline multiline
@@ -5,3 +5,9 @@ export const ArrowCornerDownRight_Stroke2_Corner2_Rounded = createSinglePathSVG(
path: 'M15.793 10.293a1 1 0 0 1 1.338-.068l.076.068 3.293 3.293a2 2 0 0 1 .138 2.677l-.138.151-3.293 3.293a1 1 0 1 1-1.414-1.414L18.086 16H8a5 5 0 0 1-5-5V5a1 1 0 0 1 2 0v6a3 3 0 0 0 3 3h10.086l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z', path: 'M15.793 10.293a1 1 0 0 1 1.338-.068l.076.068 3.293 3.293a2 2 0 0 1 .138 2.677l-.138.151-3.293 3.293a1 1 0 1 1-1.414-1.414L18.086 16H8a5 5 0 0 1-5-5V5a1 1 0 0 1 2 0v6a3 3 0 0 0 3 3h10.086l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z',
}, },
) )
export const ArrowCornerDownRight_Stroke2_Corner3_Rounded = createSinglePathSVG(
{
path: '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',
},
)
+8 -7
View File
@@ -65,7 +65,6 @@ export function ConstrainedImage({
export function AutoSizedImage({ export function AutoSizedImage({
image, image,
crop = 'constrained', crop = 'constrained',
hideBadge,
onPress, onPress,
onLongPress, onLongPress,
onPressIn, onPressIn,
@@ -74,7 +73,6 @@ export function AutoSizedImage({
}: { }: {
image: AppBskyEmbedImages.ViewImage image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained' crop?: 'none' | 'square' | 'constrained'
hideBadge?: boolean
onPress?: ( onPress?: (
containerRef: AnimatedRef<any>, containerRef: AnimatedRef<any>,
fetchedDims: Dimensions | null, fetchedDims: Dimensions | null,
@@ -146,7 +144,7 @@ export function AutoSizedImage({
/> />
<MediaInsetBorder /> <MediaInsetBorder />
{(hasAlt || isCropped) && !hideBadge ? ( {hasAlt || isCropped ? (
<View <View
accessible={false} accessible={false}
style={[ style={[
@@ -165,8 +163,10 @@ export function AutoSizedImage({
]}> ]}>
{isCropped && ( {isCropped && (
<View <View
accessible={false}
style={[ style={[
a.rounded_xs, a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ {
padding: 3, padding: 3,
@@ -186,17 +186,18 @@ export function AutoSizedImage({
)} )}
{hasAlt && ( {hasAlt && (
<View <View
accessible={false}
style={[ style={[
a.justify_center, a.justify_center,
a.rounded_xs, a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ {
padding: 3,
opacity: 0.8, opacity: 0.8,
}, },
largeAlt && [ largeAlt && [
{ {
padding: 5, padding: 6,
}, },
], ],
]}> ]}>
+4 -7
View File
@@ -115,7 +115,6 @@ export function Gallery({
const bps = useBreakpoints() const bps = useBreakpoints()
const window = useWindowDimensions() const window = useWindowDimensions()
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
const hideBadges = isWithinQuote
const contentHeight = useMemo(() => { const contentHeight = useMemo(() => {
if (isWithinChat) { if (isWithinChat) {
return 120 return 120
@@ -250,7 +249,6 @@ export function Gallery({
onPress?.(index, [containerRef], [dims]) onPress?.(index, [containerRef], [dims])
} }
onPressIn={() => onPressIn?.(index)} onPressIn={() => onPressIn?.(index)}
hideBadge={isWithinQuote}
/> />
))} ))}
</View> </View>
@@ -300,7 +298,6 @@ export function Gallery({
: undefined : undefined
return ( return (
<GalleryImage <GalleryImage
hideBadges={hideBadges}
largeAltBadge={largeAltBadge} largeAltBadge={largeAltBadge}
image={item} image={item}
contentHeight={contentHeight} contentHeight={contentHeight}
@@ -396,7 +393,6 @@ function GalleryImage({
imageCount, imageCount,
onWidthChange, onWidthChange,
itemRef, itemRef,
hideBadges,
largeAltBadge, largeAltBadge,
onContainerRef, onContainerRef,
onThumbDims, onThumbDims,
@@ -410,7 +406,6 @@ function GalleryImage({
imageCount: number imageCount: number
onWidthChange: (index: number, width: number) => void onWidthChange: (index: number, width: number) => void
itemRef: (node: View | null) => void itemRef: (node: View | null) => void
hideBadges?: boolean
largeAltBadge?: boolean largeAltBadge?: boolean
onContainerRef: (index: number, ref: AnimatedRef<any>) => void onContainerRef: (index: number, ref: AnimatedRef<any>) => void
onThumbDims: (index: number, dims: Dimensions) => void onThumbDims: (index: number, dims: Dimensions) => void
@@ -499,7 +494,7 @@ function GalleryImage({
useAppleWebpCodec useAppleWebpCodec
/> />
{!hideBadges && imageCount > 1 ? ( {imageCount > 1 ? (
<View <View
accessible={false} accessible={false}
pointerEvents="none" pointerEvents="none"
@@ -532,7 +527,7 @@ function GalleryImage({
</View> </View>
) : null} ) : null}
{(hasAlt || isCropped) && !hideBadges ? ( {hasAlt || isCropped ? (
<View <View
accessible={false} accessible={false}
style={[ style={[
@@ -549,6 +544,7 @@ function GalleryImage({
]}> ]}>
{isCropped && ( {isCropped && (
<View <View
accessible={false}
style={[ style={[
a.rounded_sm, a.rounded_sm,
a.p_xs, a.p_xs,
@@ -568,6 +564,7 @@ function GalleryImage({
)} )}
{hasAlt && ( {hasAlt && (
<View <View
accessible={false}
style={[ style={[
a.justify_center, a.justify_center,
a.rounded_sm, a.rounded_sm,
+5 -12
View File
@@ -12,7 +12,7 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, tokens, useTheme} from '#/alf' import {atoms as a, tokens, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {type PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
type EventFunction = (index: number) => void type EventFunction = (index: number) => void
@@ -42,8 +42,6 @@ export function GalleryItem({
onPress, onPress,
onPressIn, onPressIn,
onLongPress, onLongPress,
viewContext,
isWithinQuote,
insetBorderStyle, insetBorderStyle,
containerRefs, containerRefs,
thumbDimsRef, thumbDimsRef,
@@ -53,9 +51,6 @@ export function GalleryItem({
const largeAltBadge = useLargeAltBadgeEnabled() const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index] const image = images[index]
const hasAlt = !!image.alt const hasAlt = !!image.alt
const hideBadges =
isWithinQuote ??
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect = const aspect =
image.aspectRatio && image.aspectRatio.height > 0 image.aspectRatio && image.aspectRatio.height > 0
@@ -114,26 +109,24 @@ export function GalleryItem({
<MediaInsetBorder style={insetBorderStyle} /> <MediaInsetBorder style={insetBorderStyle} />
</Pressable> </Pressable>
</ImageContextMenu> </ImageContextMenu>
{hasAlt && !hideBadges ? ( {hasAlt ? (
<View <View
accessible={false} accessible={false}
style={[ style={[
a.absolute, a.absolute,
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.rounded_xs, a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ {
gap: 3,
padding: 3,
bottom: a.p_xs.padding, bottom: a.p_xs.padding,
right: a.p_xs.padding, right: a.p_xs.padding,
opacity: 0.8, opacity: 0.8,
}, },
largeAltBadge && [ largeAltBadge && [
{ {
gap: 4, padding: 6,
padding: 5,
}, },
], ],
]}> ]}>
+2 -2
View File
@@ -362,14 +362,14 @@ function MutualGroupChat({
<Button <Button
color="negative_subtle" color="negative_subtle"
disabled={isRemovePending} disabled={isRemovePending}
label={l`Kick member`} label={l`Remove member`}
size="small" size="small"
onPress={() => { onPress={() => {
onOptimisticallyRemoveConvo(view.id) onOptimisticallyRemoveConvo(view.id)
removeMembers({members: [profileDid]}) removeMembers({members: [profileDid]})
}}> }}>
<ButtonText> <ButtonText>
<Trans>Kick member</Trans> <Trans>Remove member</Trans>
</ButtonText> </ButtonText>
{isRemovePending ? <ButtonIcon icon={Loader} /> : null} {isRemovePending ? <ButtonIcon icon={Loader} /> : null}
</Button> </Button>
@@ -1,4 +1,4 @@
import {Suspense, useRef, useState} from 'react' import {Suspense, useRef} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import type ViewShot from 'react-native-view-shot' import type ViewShot from 'react-native-view-shot'
import {setStringAsync} from 'expo-clipboard' import {setStringAsync} from 'expo-clipboard'
@@ -25,6 +25,7 @@ import {IS_NATIVE} from '#/env'
import {ActionButtons} from './components/ActionButtons' import {ActionButtons} from './components/ActionButtons'
import {ThemedQrCard} from './components/ThemedQrCard' import {ThemedQrCard} from './components/ThemedQrCard'
import {ThemePicker} from './components/ThemePicker' import {ThemePicker} from './components/ThemePicker'
import {useInviteThemeKey} from './hooks/useInviteThemeKey'
import {getInviteTheme, type InviteThemeKey} from './themes' import {getInviteTheme, type InviteThemeKey} from './themes'
import {getInviteDisplayUrl, getInviteShareUrl} from './urls' import {getInviteDisplayUrl, getInviteShareUrl} from './urls'
@@ -39,7 +40,7 @@ export function InviteFriendsDialogInner({
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const profileQuery = useProfileQuery({did: currentAccount?.did}) const profileQuery = useProfileQuery({did: currentAccount?.did})
const [themeKey, setThemeKey] = useState<InviteThemeKey>('day') const [themeKey, setThemeKey] = useInviteThemeKey()
const captureRef = useRef<ViewShot>(null) const captureRef = useRef<ViewShot>(null)
const theme = getInviteTheme(themeKey) const theme = getInviteTheme(themeKey)
@@ -0,0 +1,14 @@
import {device, useStorage} from '#/storage'
import {INVITE_THEMES, type InviteThemeKey} from '../themes'
/**
* Persisted color theme for the Invite Friends QR card (APP-2409). Falls back
* to 'day' when nothing is stored or the stored key is no longer a valid
* theme.
*/
export function useInviteThemeKey() {
const [stored, setThemeKey] = useStorage(device, ['inviteFriendsThemeKey'])
const themeKey: InviteThemeKey =
stored && stored in INVITE_THEMES ? stored : 'day'
return [themeKey, setThemeKey] as const
}
+297 -30
View File
@@ -1,56 +1,323 @@
import {type ImagePickerAsset} from 'expo-image-picker' import {type ImagePickerAsset} from 'expo-image-picker'
import {
ALL_FORMATS,
type AudioCodec,
BlobSource,
BufferTarget,
canEncodeAudio,
canEncodeVideo,
Conversion,
Input,
Mp4OutputFormat,
Output,
type VideoCodec,
WebMOutputFormat,
} from 'mediabunny'
import {VIDEO_MAX_SIZE} from '#/lib/constants' import {VIDEO_MAX_SIZE} from '#/lib/constants'
import {VideoTooLargeError} from '#/lib/media/video/errors' import {VideoTooLargeError} from '#/lib/media/video/errors'
import {logger} from '#/logger'
import {hasWebCodecs} from '#/view/com/composer/videos/metadata'
import {
COMPRESSION_MAX_DIMENSION,
COMPRESSION_MIN_SIZE_BYTES,
COMPRESSION_TARGET_BITRATE,
} from './constants'
import {type CompressedVideo} from './types' import {type CompressedVideo} from './types'
// doesn't actually compress, converts to ArrayBuffer // Codecs to try in order of preference
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
const VIDEO_CODECS: VideoCodec[] = ['avc', 'hevc', 'vp9', 'vp8']
export async function compressVideo( export async function compressVideo(
asset: ImagePickerAsset, asset: ImagePickerAsset,
_opts?: { opts?: {
signal?: AbortSignal signal?: AbortSignal
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
}, },
): Promise<CompressedVideo> { ): Promise<CompressedVideo> {
const {mimeType, base64} = parseDataUrl(asset.uri) const {onProgress, signal} = opts || {}
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
logger.debug('compress: starting', {
uri: asset.uri.slice(0, 50),
hasWebCodecs: hasWebCodecs(),
})
const response = await fetch(asset.uri)
const blob = await response.blob()
const isGif = blob.type === 'image/gif'
logger.debug('compress: fetched blob', {
size: blob.size,
mimeType: blob.type,
isGif,
minSizeForCompression: COMPRESSION_MIN_SIZE_BYTES,
})
// Try MediaBunny compression if WebCodecs is available and file is large enough
// Skip GIFs - MediaBunny doesn't support them
if (hasWebCodecs() && blob.size >= COMPRESSION_MIN_SIZE_BYTES && !isGif) {
try {
return await doCompression(blob, asset.uri, {onProgress, signal})
} catch (e) {
logger.warn('compress: MediaBunny compression failed, using original', {
safeMessage: e,
})
}
} else {
logger.debug('compress: skipping compression', {
hasWebCodecs: hasWebCodecs(),
blobSize: blob.size,
minSize: COMPRESSION_MIN_SIZE_BYTES,
})
}
// No compression path - just return the blob as-is
if (blob.size > VIDEO_MAX_SIZE) { if (blob.size > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError() throw new VideoTooLargeError()
} }
return { return {
uri: asset.uri,
size: blob.size, size: blob.size,
uri,
bytes: await blob.arrayBuffer(), bytes: await blob.arrayBuffer(),
mimeType: blob.type || 'video/mp4',
}
}
async function findEncodableVideoCodec(
width: number,
height: number,
): Promise<{codec: VideoCodec; useWebM: boolean} | null> {
for (const codec of VIDEO_CODECS) {
const canEncode = await canEncodeVideo(codec, {
width,
height,
bitrate: COMPRESSION_TARGET_BITRATE,
})
logger.debug('compress: checking video codec', {
codec,
canEncode,
width,
height,
})
if (canEncode) {
// vp8/vp9 need WebM container, others use MP4
const useWebM = codec === 'vp8' || codec === 'vp9'
return {codec, useWebM}
}
}
return null
}
// Audio codecs to try - aac for MP4, opus for WebM
const AUDIO_CODECS_MP4: AudioCodec[] = ['aac']
const AUDIO_CODECS_WEBM: AudioCodec[] = ['opus', 'vorbis']
async function findEncodableAudioCodec(
audioTrack: Awaited<ReturnType<Input['getPrimaryAudioTrack']>>,
useWebM: boolean,
): Promise<{codec: AudioCodec} | null> {
if (!audioTrack) {
return null
}
// First check if we can decode the source audio
const canDecodeSource = await audioTrack.canDecode()
logger.debug('compress: checking audio source', {
sourceCodec: audioTrack.codec,
canDecode: canDecodeSource,
channels: audioTrack.numberOfChannels,
sampleRate: audioTrack.sampleRate,
})
if (!canDecodeSource) {
return null
}
const codecsToTry = useWebM ? AUDIO_CODECS_WEBM : AUDIO_CODECS_MP4
for (const codec of codecsToTry) {
const canEncode = await canEncodeAudio(codec, {
numberOfChannels: audioTrack.numberOfChannels,
sampleRate: audioTrack.sampleRate,
})
logger.debug('compress: checking audio encode codec', {
codec,
canEncode,
})
if (canEncode) {
return {codec}
}
}
return null
}
async function doCompression(
blob: Blob,
originalUri: string,
opts: {
onProgress?: (progress: number) => void
signal?: AbortSignal
},
): Promise<CompressedVideo> {
const {onProgress, signal} = opts
const input = new Input({
source: new BlobSource(blob),
formats: ALL_FORMATS,
})
// Get video track to determine dimensions for codec check
const videoTrack = await input.getPrimaryVideoTrack()
if (!videoTrack) {
input.dispose()
throw new Error('No video track found')
}
// Get audio track to check if we can encode it
const audioTrack = await input.getPrimaryAudioTrack()
const {width, height} = calculateDimensions(
videoTrack.displayWidth,
videoTrack.displayHeight,
COMPRESSION_MAX_DIMENSION,
)
logger.debug('compress: video dimensions', {
original: {
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
},
target: {width, height},
audioCodec: audioTrack?.codec,
})
// Find a video codec we can encode with
const codecInfo = await findEncodableVideoCodec(width, height)
if (!codecInfo) {
input.dispose()
throw new Error('No supported video codec available')
}
// Check if we can encode the audio
const audioCodecInfo = await findEncodableAudioCodec(
audioTrack,
codecInfo.useWebM,
)
logger.debug('compress: using codecs', {
video: codecInfo.codec,
audio: audioCodecInfo?.codec ?? 'none',
useWebM: codecInfo.useWebM,
})
const target = new BufferTarget()
const output = new Output({
format: codecInfo.useWebM ? new WebMOutputFormat() : new Mp4OutputFormat(),
target,
})
// If we have audio but can't encode it, bail out and use the original
if (audioTrack && !audioCodecInfo) {
input.dispose()
throw new Error(
`Cannot encode audio codec: ${audioTrack.codec ?? 'unknown'}`,
)
}
const conversion = await Conversion.init({
input,
output,
video: {
codec: codecInfo.codec,
bitrate: COMPRESSION_TARGET_BITRATE,
width,
height,
fit: 'contain',
},
audio: audioCodecInfo ? {codec: audioCodecInfo.codec} : undefined,
})
if (onProgress) {
conversion.onProgress = onProgress
}
if (signal) {
signal.addEventListener(
'abort',
() => {
logger.debug('compress: cancelled')
void conversion.cancel()
},
{once: true},
)
}
logger.debug('compress: starting conversion')
const startTime = performance.now()
try {
await conversion.execute()
} finally {
input.dispose()
}
const elapsed = performance.now() - startTime
const bytes = target.buffer
if (!bytes) {
// mediabunny's BufferTarget reports a null buffer after a successful
// execute(). Should not happen in normal use; recoverable here because
// the outer compressVideo() catches and falls back to the original blob,
// but worth flagging in Sentry so we can chase the root cause.
const err = new Error('Compression produced empty output')
logger.error(err, {})
throw err
}
const mimeType = codecInfo.useWebM ? 'video/webm' : 'video/mp4'
const savedBytes = blob.size - bytes.byteLength
const savedPercent = ((savedBytes / blob.size) * 100).toFixed(1)
logger.debug('compress: completed', {
from: blob.type,
to: mimeType,
originalSize: blob.size,
compressedSize: bytes.byteLength,
savedBytes,
savedPercent: `${savedPercent}%`,
elapsedMs: Math.round(elapsed),
})
if (bytes.byteLength > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError()
}
return {
uri: originalUri,
size: bytes.byteLength,
bytes,
mimeType, mimeType,
} }
} }
function parseDataUrl(dataUrl: string) { function calculateDimensions(
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,') width: number,
if (!mimeType || !base64) { height: number,
throw new Error('Invalid data URL') maxDimension: number,
): {width: number; height: number} {
const maxSide = Math.max(width, height)
if (maxSide <= maxDimension) {
return {width, height}
}
const scale = maxDimension / maxSide
return {
width: Math.round(width * scale),
height: Math.round(height * scale),
} }
return {mimeType, base64}
}
function base64ToBlob(base64: string, mimeType: string) {
const byteCharacters = atob(base64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, {type: mimeType})
} }
+8
View File
@@ -0,0 +1,8 @@
// Shared compression knobs. Mirrored between native (compress.ts) and web
// (compress.web.ts) so both platforms produce videos with the same target.
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3 Mbps
export const COMPRESSION_MAX_DIMENSION = 1920
// Web skips compression entirely for files under this size; native applies its
// own threshold logic inside expo-bluesky-video-compress's probe step.
export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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