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:
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME }}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -10,6 +10,23 @@ on:
|
||||
options:
|
||||
- testflight-android
|
||||
- production
|
||||
workflow_call:
|
||||
inputs:
|
||||
profile:
|
||||
type: string
|
||||
description: Build profile to use
|
||||
required: true
|
||||
outputs:
|
||||
package-version:
|
||||
description: Version from package.json
|
||||
value: ${{ jobs.build.outputs.package-version }}
|
||||
version-code:
|
||||
description: Android version code
|
||||
value: ${{ jobs.build.outputs.version-code }}
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -19,6 +36,10 @@ jobs:
|
||||
concurrency:
|
||||
group: android-build
|
||||
cancel-in-progress: false
|
||||
outputs:
|
||||
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
||||
version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
|
||||
apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -28,32 +49,32 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -62,7 +83,8 @@ jobs:
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
|
||||
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: Env
|
||||
@@ -92,99 +114,76 @@ jobs:
|
||||
--profile $PROFILE
|
||||
--local --output build.aab --non-interactive
|
||||
|
||||
- name: ✍️ Rename Testflight bundle
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
run: mv build.aab build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
uses: nanzm/get-time-action@master
|
||||
with:
|
||||
format: "MM-DD-HH-mm-ss"
|
||||
|
||||
- name: 🚀 Upload Production Artifact
|
||||
id: upload-artifact-production
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.aab
|
||||
path: build.aab
|
||||
|
||||
- name: 🚀 Upload Testflight Artifact
|
||||
id: upload-artifact-testflight
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 📚 Get version from package.json
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
- name: 🚀 Submit to Google Play
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight-android' }}
|
||||
run: pnpm eas submit -p android --profile $PROFILE --non-interactive --path build.aab
|
||||
|
||||
- name: 🔔 Notify Slack of Play Store Submission
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android production build for Google Play Store submission is ready!\n```Artifact: ${{ steps.upload-artifact-production.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
|
||||
{"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
|
||||
|
||||
- name: 🔔 Notify Slack of Testflight Build
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
- name: 🔧 Setup bundletool
|
||||
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact-testflight.outputs.artifact-url }}"}
|
||||
bundletool-version: "1.17.2"
|
||||
|
||||
- name: 🧹 Clear Metro cache
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
# https://github.com/expo/eas-cli/issues/2959#issuecomment-2749791326
|
||||
run: rm -rf ${TMPDIR:-/tmp}/metro-cache
|
||||
- name: 🔑 Decode keystore
|
||||
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
|
||||
keystore.jks
|
||||
|
||||
- name: 🏗️ Build Production APK
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p android
|
||||
--profile production-apk
|
||||
--local --output build.apk --non-interactive
|
||||
- name: 📦 Build signed universal APK
|
||||
run: |
|
||||
bundletool build-apks \
|
||||
--bundle=build.aab \
|
||||
--output=universal.apks \
|
||||
--mode=universal \
|
||||
--ks=keystore.jks \
|
||||
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
|
||||
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
|
||||
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
- name: 🚀 Upload Production APK Artifact
|
||||
id: upload-artifact-production-apk
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
- name: 📋 Rename to .zip for extraction
|
||||
run: mv universal.apks universal.zip
|
||||
|
||||
- name: 📦 Extract universal APK
|
||||
run: unzip -p universal.zip universal.apk > build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🚀 Upload APK Artifact
|
||||
id: upload-artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack of Production APK Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
- name: 🔔 Notify Slack of APK Artifact
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android production build for GitHub/Obtanium is ready!\n```Artifact: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
|
||||
{"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
@@ -195,3 +194,62 @@ jobs:
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
# Releases are cut from tags named after the version (e.g. "1.124.0"), so when a production
|
||||
# build is dispatched against such a tag we attach the APK to the matching release. This runs
|
||||
# as a separate job so that `contents: write` is isolated here and the build job stays read-only.
|
||||
attachToRelease:
|
||||
name: Attach APK to GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
if: ${{ inputs.profile == 'production' && github.ref_type == 'tag' && github.repository == 'bluesky-social/social-app' }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# We only attach to a release that already exists — never create one.
|
||||
- name: 🔎 Check for matching GitHub Release
|
||||
id: release-check
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
status=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "Found GitHub Release for tag $TAG"
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No GitHub Release found for tag $TAG (HTTP $status); skipping APK attachment."
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: ⬇️ Download APK artifact
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: ${{ needs.build.outputs.apk-artifact-name }}
|
||||
|
||||
- name: 🏷️ Rename APK for release
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
run: cp build.apk "Bluesky-${{ needs.build.outputs.package-version }}.apk"
|
||||
|
||||
- name: 📎 Attach APK to GitHub Release
|
||||
id: attach
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: Bluesky-${{ needs.build.outputs.package-version }}.apk
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
- name: 🔔 Notify Slack of Release Attachment
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android APK attached to GitHub Release ${{ github.ref_name }}!\n```Asset: Bluesky-${{ needs.build.outputs.package-version }}.apk\nRelease: ${{ steps.attach.outputs.url }}```"}
|
||||
|
||||
@@ -10,6 +10,35 @@ on:
|
||||
options:
|
||||
- testflight
|
||||
- production
|
||||
testFlightGroup:
|
||||
type: choice
|
||||
description: TestFlight group to assign the build to after submitting
|
||||
options:
|
||||
- none
|
||||
- QA Team
|
||||
- Software Mansion
|
||||
default: none
|
||||
workflow_call:
|
||||
inputs:
|
||||
profile:
|
||||
type: string
|
||||
description: Build profile to use
|
||||
required: true
|
||||
testFlightGroup:
|
||||
type: string
|
||||
description: TestFlight group to assign the build to after submitting ("none" to skip)
|
||||
default: none
|
||||
outputs:
|
||||
package-version:
|
||||
description: Version from package.json
|
||||
value: ${{ jobs.build.outputs.package-version }}
|
||||
build-number:
|
||||
description: iOS build number
|
||||
value: ${{ jobs.build.outputs.build-number }}
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -19,6 +48,9 @@ jobs:
|
||||
concurrency:
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
outputs:
|
||||
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
||||
build-number: ${{ steps.ipa-build-number.outputs.build-number }}
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -28,42 +60,48 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
with:
|
||||
version: 1.16.2
|
||||
- name: ☕️ Assert Cocoapods version
|
||||
run: |
|
||||
EXPECTED=1.16.2
|
||||
ACTUAL=$(pod --version)
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||
echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md"
|
||||
echo "If the runner image changed, update EXPECTED here or reinstall the pinned version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
@@ -75,7 +113,8 @@ jobs:
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
|
||||
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: ✏️ Write environment variables
|
||||
@@ -153,19 +192,76 @@ jobs:
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
|
||||
# Read the build number straight from the IPA's CFBundleVersion. This is the value
|
||||
# baked in at build time by use-build-number-with-bump (remote counter + 1) and the
|
||||
# number that actually lands in App Store Connect. `eas build:version:get` reads the
|
||||
# remote counter, which a --local build does not advance, so it can be off by one —
|
||||
# using it here would make distribute_only poll for a nonexistent build.
|
||||
- name: 🔢 Read build number from IPA
|
||||
id: ipa-build-number
|
||||
run: |
|
||||
plist_dir="$(mktemp -d)"
|
||||
unzip -o -q "$BUILD_DIR/Bluesky.ipa" 'Payload/*.app/Info.plist' -d "$plist_dir"
|
||||
plist="$(find "$plist_dir" -name Info.plist -print -quit)"
|
||||
build_number="$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$plist")"
|
||||
rm -rf "$plist_dir"
|
||||
if [ -z "$build_number" ]; then
|
||||
echo "ERROR: could not read CFBundleVersion from IPA"
|
||||
exit 1
|
||||
fi
|
||||
echo "IPA build number: $build_number"
|
||||
echo "build-number=$build_number" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# eas submit only uploads to App Store Connect; it can't assign a build to a
|
||||
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
|
||||
# already-submitted build to the group, polling until Apple finishes processing it.
|
||||
- name: 🧪 Assign build to TestFlight group
|
||||
if: ${{ inputs.testFlightGroup != 'none' }}
|
||||
env:
|
||||
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
|
||||
APP_VERSION: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
||||
BUILD_NUMBER: ${{ steps.ipa-build-number.outputs.build-number }}
|
||||
run: |
|
||||
# Ensure the API key material is removed even if fastlane exits non-zero
|
||||
# (the step runs under `bash -e`, which would otherwise abort before cleanup).
|
||||
trap 'rm -f asc_api_key.json' EXIT
|
||||
# fastlane's Token.from_json_file expects the .p8 contents inline under "key"
|
||||
# (PEM with embedded newlines), not a path. jq handles the newline escaping.
|
||||
key_content="$(echo "$ASC_KEY_P8_BASE64" | base64 --decode)"
|
||||
jq -n \
|
||||
--arg key_id "$ASC_KEY_ID" \
|
||||
--arg issuer_id "$ASC_ISSUER_ID" \
|
||||
--arg key "$key_content" \
|
||||
'{key_id: $key_id, issuer_id: $issuer_id, key: $key, in_house: false}' \
|
||||
> asc_api_key.json
|
||||
# app_platform is required in non-interactive mode: distribute_only otherwise
|
||||
# calls fetch_app_platform, which prompts for input and crashes without a TTY.
|
||||
fastlane run upload_to_testflight \
|
||||
api_key_path:"$PWD/asc_api_key.json" \
|
||||
distribute_only:true \
|
||||
app_platform:"ios" \
|
||||
app_identifier:"xyz.blueskyweb.app" \
|
||||
app_version:"$APP_VERSION" \
|
||||
build_number:"$BUILD_NUMBER" \
|
||||
groups:"$TESTFLIGHT_GROUP" \
|
||||
notify_external_testers:true
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_IOS_BUILD_NUMBER }}```"}
|
||||
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.ipa-build-number.outputs.build-number }}```"}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
|
||||
@@ -18,6 +18,10 @@ on:
|
||||
description: Runtime version (in x.x.x format) that this update is for
|
||||
required: true
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
bundleDeploy:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
@@ -48,7 +52,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -56,17 +60,17 @@ jobs:
|
||||
if: ${{ github.ref != 'refs/heads/main' }}
|
||||
run: git fetch origin main:main --depth 100
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
uses: bluesky-social/github-actions/fingerprint-native@main
|
||||
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
|
||||
with:
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
||||
@@ -75,7 +79,8 @@ jobs:
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
|
||||
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
@@ -87,7 +92,7 @@ jobs:
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
@@ -96,7 +101,7 @@ jobs:
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
# eas.json not used here, set EXPO_PUBLIC_ENV
|
||||
- name: Env
|
||||
@@ -137,7 +142,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
@@ -157,7 +162,9 @@ jobs:
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
# available here
|
||||
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app' }}
|
||||
if: ${{ inputs.channel != 'production' &&
|
||||
needs.bundleDeploy.outputs.changes-detected && github.repository ==
|
||||
'bluesky-social/social-app' }}
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -167,20 +174,20 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -189,17 +196,23 @@ jobs:
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
with:
|
||||
version: 1.16.2
|
||||
- name: ☕️ Assert Cocoapods version
|
||||
run: |
|
||||
EXPECTED=1.16.2
|
||||
ACTUAL=$(pod --version)
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||
echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md"
|
||||
echo "If the runner image changed, update EXPECTED here or reinstall the pinned version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
@@ -282,7 +295,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ inputs.channel == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
@@ -303,7 +316,9 @@ jobs:
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
# available here
|
||||
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app'}}
|
||||
if: ${{ inputs.channel != 'production' &&
|
||||
needs.bundleDeploy.outputs.changes-detected && github.repository ==
|
||||
'bluesky-social/social-app'}}
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
@@ -314,26 +329,26 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -368,17 +383,49 @@ jobs:
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p android
|
||||
--profile testflight-android
|
||||
--local --output build.apk --non-interactive
|
||||
--local --output build.aab --non-interactive
|
||||
|
||||
- name: 📚 Get version from package.json
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
|
||||
- name: 🚀 Submit to Google Play
|
||||
run: pnpm eas submit -p android --profile testflight-android --non-interactive --path
|
||||
build.aab
|
||||
|
||||
- name: 🔧 Setup bundletool
|
||||
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
|
||||
with:
|
||||
bundletool-version: "1.17.2"
|
||||
|
||||
- name: 🔑 Decode keystore
|
||||
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
|
||||
keystore.jks
|
||||
|
||||
- name: 📦 Build signed universal APK
|
||||
run: |
|
||||
bundletool build-apks \
|
||||
--bundle=build.aab \
|
||||
--output=universal.apks \
|
||||
--mode=universal \
|
||||
--ks=keystore.jks \
|
||||
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
|
||||
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
|
||||
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
- name: 📋 Rename to .zip for extraction
|
||||
run: mv universal.apks universal.zip
|
||||
|
||||
- name: 📦 Extract universal APK
|
||||
run: unzip -p universal.zip universal.apk > build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
uses: nanzm/get-time-action@master
|
||||
with:
|
||||
format: "MM-DD-HH-mm-ss"
|
||||
run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🚀 Upload Artifact
|
||||
id: upload-artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
@@ -386,7 +433,7 @@ jobs:
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
@@ -396,7 +443,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
|
||||
@@ -54,18 +54,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
|
||||
@@ -40,18 +40,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
|
||||
@@ -10,14 +10,17 @@ concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
@@ -33,9 +36,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
job: [lint, prettier, typecheck]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Verify Node version pins match package.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -50,9 +50,9 @@ jobs:
|
||||
v=$(grep -oE '"node":[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' eas.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
|
||||
check "eas.json" "$v"
|
||||
exit $rc
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -87,10 +87,10 @@ jobs:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: Nightly Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "10 2 * * *" # run at 2:10 AM UTC, after the nightly i18n job
|
||||
workflow_dispatch:
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Generate the changelog once, shared by both platforms. The range covers commits since
|
||||
# the previous nightly, whose commit SHA is stored as a "nightly-build-commit" artifact
|
||||
# (advanced by the record job below, only after both builds succeed).
|
||||
prepare:
|
||||
name: Prepare release notes
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
notes: ${{ steps.notes.outputs.notes }}
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 📝 Generate release notes
|
||||
id: notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Find the most recent non-expired nightly-build-commit artifact and read its SHA
|
||||
# The artifacts API returns results newest-first, so the most recent marker is on
|
||||
# page 1 — no --paginate needed (which would run the jq aggregation per page and
|
||||
# could emit multiple URLs). Take the first non-expired match.
|
||||
prev=""
|
||||
url=$(gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/artifacts?name=nightly-build-commit&per_page=100" \
|
||||
--jq 'first(.artifacts[] | select(.expired == false)) | .archive_download_url' \
|
||||
2>/dev/null || true)
|
||||
if [ -n "$url" ] && [ "$url" != "null" ]; then
|
||||
if curl -sSL -H "Authorization: Bearer $GH_TOKEN" -o marker.zip "$url" \
|
||||
&& unzip -o -q marker.zip; then
|
||||
prev=$(cat nightly-build-commit.txt 2>/dev/null | tr -d '[:space:]')
|
||||
fi
|
||||
rm -f marker.zip nightly-build-commit.txt
|
||||
fi
|
||||
|
||||
if [ -n "$prev" ] && git cat-file -e "${prev}^{commit}" 2>/dev/null; then
|
||||
echo "Generating notes since previous nightly: $prev"
|
||||
range="${prev}..HEAD"
|
||||
else
|
||||
echo "No reachable previous nightly commit; falling back to last 30 commits."
|
||||
range="HEAD~30..HEAD"
|
||||
fi
|
||||
notes=$(git log --no-merges --pretty=format:'- %s' "$range" 2>/dev/null | head -n 50)
|
||||
if [ -z "$notes" ]; then
|
||||
notes="Nightly build — no new commits since the last nightly."
|
||||
fi
|
||||
# Cap the whole changelog to keep the Slack message a reasonable size.
|
||||
# head -c caps the combined stream; cut -c would only cap each line independently.
|
||||
notes=$(printf '%s' "$notes" | head -c 3900)
|
||||
{
|
||||
echo "notes<<NOTES_EOF"
|
||||
echo "$notes"
|
||||
echo "NOTES_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
ios:
|
||||
name: Nightly iOS Build
|
||||
needs: [prepare]
|
||||
uses: ./.github/workflows/build-submit-ios.yml
|
||||
with:
|
||||
profile: testflight
|
||||
testFlightGroup: "QA Team"
|
||||
secrets: inherit
|
||||
|
||||
android:
|
||||
name: Nightly Android Build
|
||||
needs: [prepare]
|
||||
# build-submit-android.yml contains an attachToRelease job that requests contents: write.
|
||||
# That job is skipped for nightly (it needs a production tag build), but GitHub statically
|
||||
# validates the reusable-workflow permission ceiling, so the caller must grant it here.
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/build-submit-android.yml
|
||||
with:
|
||||
profile: testflight-android
|
||||
secrets: inherit
|
||||
|
||||
notify-ios:
|
||||
name: Notify Slack of iOS nightly
|
||||
needs: [prepare, ios]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 📝 Build Slack payload
|
||||
id: payload
|
||||
env:
|
||||
NOTES: ${{ needs.prepare.outputs.notes }}
|
||||
VERSION: ${{ needs.ios.outputs.package-version }}
|
||||
BUILD_NUMBER: ${{ needs.ios.outputs.build-number }}
|
||||
run: |
|
||||
text="*Nightly iOS build available in TestFlight (QA Team)*
|
||||
Version ${VERSION} (${BUILD_NUMBER})
|
||||
|
||||
${NOTES}"
|
||||
payload=$(jq -n --arg text "$text" '{text: $text}')
|
||||
{
|
||||
echo "payload<<PAYLOAD_EOF"
|
||||
echo "$payload"
|
||||
echo "PAYLOAD_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: ${{ steps.payload.outputs.payload }}
|
||||
|
||||
notify-android:
|
||||
name: Notify Slack of Android nightly
|
||||
needs: [prepare, android]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 📝 Build Slack payload
|
||||
id: payload
|
||||
env:
|
||||
NOTES: ${{ needs.prepare.outputs.notes }}
|
||||
VERSION: ${{ needs.android.outputs.package-version }}
|
||||
VERSION_CODE: ${{ needs.android.outputs.version-code }}
|
||||
run: |
|
||||
text="*Nightly Android build available (Internal track)*
|
||||
Version ${VERSION} (${VERSION_CODE})
|
||||
|
||||
${NOTES}"
|
||||
payload=$(jq -n --arg text "$text" '{text: $text}')
|
||||
{
|
||||
echo "payload<<PAYLOAD_EOF"
|
||||
echo "$payload"
|
||||
echo "PAYLOAD_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: ${{ steps.payload.outputs.payload }}
|
||||
|
||||
# Advance the nightly marker only after both builds succeed, so a failed night's commits
|
||||
# roll into the next successful nightly's notes rather than being silently dropped.
|
||||
record:
|
||||
name: Record nightly commit
|
||||
needs: [ios, android]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ✏️ Write nightly commit marker
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo "$GITHUB_SHA" > nightly-build-commit.txt
|
||||
|
||||
- name: 🚀 Upload nightly commit marker
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: nightly-build-commit
|
||||
path: nightly-build-commit.txt
|
||||
retention-days: 90
|
||||
@@ -16,12 +16,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -30,12 +30,12 @@ jobs:
|
||||
- name: Extract language strings
|
||||
run: pnpm intl:extract
|
||||
- name: Create commit
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
|
||||
with:
|
||||
commit_message: Nightly source-language update
|
||||
file_pattern: ./src/locale/locales/en/messages.po
|
||||
- name: Push source lang to Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_sources_args: "-b main"
|
||||
|
||||
@@ -5,16 +5,15 @@ on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# Permissiosn to make comments in the pull request
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
contents: read
|
||||
# Permissions are granted per-job below; anything unlisted defaults to none
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
handle-comment:
|
||||
if: github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-deploy: ${{ steps.check-org.outputs.result }}
|
||||
|
||||
@@ -45,10 +44,10 @@ jobs:
|
||||
echo "mentioned=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check organization membership
|
||||
- name: Check commenter has write access
|
||||
if: steps.check-mention.outputs.mentioned == 'true'
|
||||
id: check-org
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
@@ -72,13 +71,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [handle-comment]
|
||||
if: needs.handle-comment.outputs.should-deploy == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Get PR HEAD SHA
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
id: pr-info
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
@@ -87,6 +89,16 @@ jobs:
|
||||
pull_number: process.env.ISSUE_NUMBER,
|
||||
});
|
||||
|
||||
// This workflow runs with repo secrets in scope, so never build
|
||||
// code from a fork: the commenter authorizes the deploy, but a
|
||||
// fork controls what code would run during it
|
||||
const expected = `${context.repo.owner}/${context.repo.repo}`;
|
||||
const head = pr.data.head.repo?.full_name;
|
||||
if (head !== expected) {
|
||||
core.setFailed(`OTA deploys are only allowed for branches in ${expected}, not forks (got ${head})`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
|
||||
console.log(`PR HEAD REF: ${pr.data.head.ref}`);
|
||||
|
||||
@@ -94,7 +106,7 @@ jobs:
|
||||
core.setOutput('head-ref', pr.data.head.ref);
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
@@ -113,14 +125,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ steps.pr-info.outputs.head-sha }}
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -141,14 +153,14 @@ jobs:
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v9
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
- name: Env
|
||||
id: env
|
||||
@@ -181,7 +193,7 @@ jobs:
|
||||
RUNTIME_VERSION:
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
@@ -198,7 +210,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: failure()
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
|
||||
@@ -13,25 +13,28 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
contents: read
|
||||
# Permissions are granted per-job below; anything unlisted defaults to none.
|
||||
# pull-requests: write is needed by sticky-pull-request-comment to post the
|
||||
# bundle-size and fingerprint diffs
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
webpack-analyzer:
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -68,7 +71,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Get base stats from cache
|
||||
id: get-base-stats
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: stats-base.json
|
||||
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
|
||||
@@ -89,14 +92,14 @@ jobs:
|
||||
|
||||
- name: % Get diff
|
||||
id: get-diff
|
||||
uses: NejcZdovc/bundle-size-diff@v1
|
||||
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
|
||||
with:
|
||||
base_path: "stats-base.json"
|
||||
pr_path: "../stats-new.json"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: bundle-diff
|
||||
message: |
|
||||
@@ -108,9 +111,12 @@ jobs:
|
||||
fingerprint-native:
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 100
|
||||
|
||||
@@ -118,22 +124,22 @@ jobs:
|
||||
run: git fetch origin main:main --depth 100
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
uses: bluesky-social/github-actions/fingerprint-native@main
|
||||
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
|
||||
with:
|
||||
profile: pull-request
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
@@ -151,7 +157,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Delete comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
|
||||
@@ -4,28 +4,37 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# The push to the internal repo uses the app token below, not the GITHUB_TOKEN
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
steps:
|
||||
- name: Checkout public repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Don't persist the checkout auth header; the push below authenticates
|
||||
# with the app token embedded in the remote URL instead
|
||||
persist-credentials: false
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||
repositories: social-app-internal
|
||||
# Scope the token down from the app's full installation permissions;
|
||||
# pushing is the only thing this token is used for
|
||||
permission-contents: write
|
||||
- name: Push to internal repo
|
||||
env:
|
||||
TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email "test@users.noreply.github.com"
|
||||
git config --unset-all http.https://github.com/.extraheader
|
||||
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
|
||||
git push internal main --force
|
||||
|
||||
@@ -6,13 +6,16 @@ concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify-pnpm-lock:
|
||||
name: No manual pnpm-lock.yaml edits
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out PR HEAD
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -21,10 +24,10 @@ jobs:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git fetch origin $BASE_REF --depth=1
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Workflow security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: [".github/**"]
|
||||
pull_request:
|
||||
paths: [".github/**"]
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
# The github.token is only used by zizmor's online audits (read-only API calls)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Audit workflows with zizmor
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
with:
|
||||
# Annotate the PR directly instead of uploading SARIF to the
|
||||
# security tab, and fail the check on any finding
|
||||
advanced-security: false
|
||||
annotations: true
|
||||
# Low-confidence findings (e.g. artipacked on workflows that never
|
||||
# push) are too noisy to gate CI on
|
||||
min-confidence: medium
|
||||
@@ -145,21 +145,21 @@ appId: xyz.blueskyweb.app
|
||||
id: "profileHeaderDropdownBtn"
|
||||
- tapOn: "Add to Lists"
|
||||
- assertVisible:
|
||||
id: "userAddRemoveListsModal"
|
||||
id: "userAddRemoveListsDialog"
|
||||
- tapOn:
|
||||
id: "user-bob.test-addBtn"
|
||||
- tapOn:
|
||||
id: "doneBtn"
|
||||
- assertNotVisible:
|
||||
id: "userAddRemoveListsModal"
|
||||
id: "userAddRemoveListsDialog"
|
||||
- tapOn:
|
||||
id: "profileHeaderDropdownBtn"
|
||||
- tapOn: "Add to Lists"
|
||||
- assertVisible:
|
||||
id: "userAddRemoveListsModal"
|
||||
id: "userAddRemoveListsDialog"
|
||||
- tapOn:
|
||||
id: "user-bob.test-addBtn"
|
||||
- tapOn:
|
||||
id: "doneBtn"
|
||||
- assertNotVisible:
|
||||
id: "userAddRemoveListsModal"
|
||||
id: "userAddRemoveListsDialog"
|
||||
|
||||
|
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 |
@@ -65,6 +65,7 @@ type discussionForumPosting struct {
|
||||
CommentCount *int64 `json:"commentCount,omitempty"`
|
||||
Comment []comment `json:"comment,omitempty"`
|
||||
IsBasedOn string `json:"isBasedOn,omitempty"`
|
||||
IsPartOf string `json:"isPartOf,omitempty"`
|
||||
SharedContent *sharedContent `json:"sharedContent,omitempty"`
|
||||
}
|
||||
|
||||
@@ -344,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// threadRootURI returns the AT-URI of the root post of the thread a reply
|
||||
// belongs to, or "" if the post is not a reply or the record is malformed.
|
||||
func threadRootURI(pv *appbsky.FeedDefs_PostView) string {
|
||||
if pv == nil || pv.Record == nil {
|
||||
return ""
|
||||
}
|
||||
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
|
||||
if !ok || rec.Reply == nil || rec.Reply.Root == nil {
|
||||
return ""
|
||||
}
|
||||
return rec.Reply.Root.Uri
|
||||
}
|
||||
|
||||
// findRootPostInParents walks tv's parent chain upward and returns the
|
||||
// PostView whose URI matches rootURI, or nil if the root is not present.
|
||||
// The root is absent when the chain is truncated by parentHeight (reply
|
||||
// deeper than the fetched height) or broken by a blocked/not-found parent.
|
||||
// Avoids a separate FeedGetPosts call when the thread response already
|
||||
// contains the root.
|
||||
func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView {
|
||||
if rootURI == "" {
|
||||
return nil
|
||||
}
|
||||
for node := tv; node != nil; {
|
||||
if node.Post != nil && node.Post.Uri == rootURI {
|
||||
return node.Post
|
||||
}
|
||||
if node.Parent == nil {
|
||||
return nil
|
||||
}
|
||||
// Only threadViewPost parents continue the chain; a blocked or
|
||||
// not-found parent breaks it before reaching the root.
|
||||
node = node.Parent.FeedDefs_ThreadViewPost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAuthor constructs a Person. Organization classification for
|
||||
// custom-domain accounts is a future enhancement.
|
||||
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
|
||||
@@ -584,13 +622,21 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
|
||||
|
||||
// buildPostJSONLD marshals the WebPage envelope wrapping a
|
||||
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
|
||||
// (as a fallback) mainEntity.url so they always agree.
|
||||
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
|
||||
// (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
|
||||
// non-empty, is the handle-form canonical URL of the thread root the handler
|
||||
// resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could
|
||||
// not be resolved). We never emit a DID-form isPartOf because it would not
|
||||
// match the root page's handle-form canonical.
|
||||
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
|
||||
if pv == nil || pv.Author == nil {
|
||||
return "", fmt.Errorf("nil post view or author")
|
||||
}
|
||||
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
|
||||
|
||||
if isPartOfURL != "" {
|
||||
node.IsPartOf = isPartOfURL
|
||||
}
|
||||
|
||||
// mainEntity.url is empty when the author handle is unusable; fall back
|
||||
// to canonicalURL so it agrees with envelope.url.
|
||||
if node.URL == "" {
|
||||
|
||||
@@ -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.
|
||||
func withSelfLabel(val string) func(*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) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -333,7 +346,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -358,7 +371,7 @@ func TestBuildPostJSONLD_WithGallery(t *testing.T) {
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
||||
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
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"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
|
||||
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -438,7 +451,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
|
||||
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["thumbnailUrl"] != thumb {
|
||||
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
|
||||
@@ -451,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
||||
|
||||
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
|
||||
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
|
||||
@@ -460,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
|
||||
|
||||
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["isBasedOn"]; present {
|
||||
t.Errorf("blocked quote should not produce isBasedOn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_IsPartOf(t *testing.T) {
|
||||
// isPartOf is sourced solely from the handler-resolved URL. When supplied,
|
||||
// it is emitted on the main post; when empty, no isPartOf is present.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply")
|
||||
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["isPartOf"] != isPartOf {
|
||||
t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf)
|
||||
}
|
||||
|
||||
out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main = unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["isPartOf"]; present {
|
||||
t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadRootURI(t *testing.T) {
|
||||
// A reply returns its root AT-URI; a non-reply returns "".
|
||||
reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply",
|
||||
withReplyRoot("did:plc:root", "rootrkey"))
|
||||
want := "at://did:plc:root/app.bsky.feed.post/rootrkey"
|
||||
if got := threadRootURI(reply); got != want {
|
||||
t.Errorf("threadRootURI = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply")
|
||||
if got := threadRootURI(post); got != "" {
|
||||
t.Errorf("threadRootURI on non-reply = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRootPostInParents(t *testing.T) {
|
||||
rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root")
|
||||
rootURI := rootPost.Uri
|
||||
|
||||
// tvp wraps a PostView, optionally chaining to a parent thread node.
|
||||
tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost {
|
||||
node := &appbsky.FeedDefs_ThreadViewPost{Post: pv}
|
||||
if parent != nil {
|
||||
node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent}
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
t.Run("direct reply, parent is root", func(t *testing.T) {
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
|
||||
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
|
||||
t.Errorf("expected root post, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-level chain", func(t *testing.T) {
|
||||
mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil))
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid)
|
||||
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
|
||||
t.Errorf("expected root post in chain, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("root absent, chain truncated", func(t *testing.T) {
|
||||
// Topmost parent is not the root (e.g. parentHeight cut off the chain).
|
||||
topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid")
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil))
|
||||
if got := findRootPostInParents(leaf, rootURI); got != nil {
|
||||
t.Errorf("expected nil when root absent, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chain broken by blocked parent", func(t *testing.T) {
|
||||
// A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost,
|
||||
// breaking the walk before the root.
|
||||
leaf := &appbsky.FeedDefs_ThreadViewPost{
|
||||
Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"),
|
||||
Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{
|
||||
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI},
|
||||
},
|
||||
}
|
||||
if got := findRootPostInParents(leaf, rootURI); got != nil {
|
||||
t.Errorf("expected nil when chain broken by blocked parent, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty rootURI", func(t *testing.T) {
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
|
||||
if got := findRootPostInParents(leaf, ""); got != nil {
|
||||
t.Errorf("expected nil for empty rootURI, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) {
|
||||
// Replies surfaced under the main post as comment[] are Comment nodes and
|
||||
// never carry isPartOf, even when the main post has one.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply")
|
||||
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
if _, present := c["isPartOf"]; present {
|
||||
t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
sc, ok := main["sharedContent"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -487,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/x@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||
withImages(thumb), withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["image"]; present {
|
||||
t.Errorf("hidden-embed post should not emit image")
|
||||
@@ -506,7 +625,7 @@ func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/g@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||
withGallery(thumb), withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["image"]; present {
|
||||
t.Errorf("hidden-embed gallery post should not emit image")
|
||||
@@ -520,7 +639,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
|
||||
// Includes ", \, newline, </script>, and a unicode char.
|
||||
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -556,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
|
||||
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
|
||||
})
|
||||
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
|
||||
if cc := main["commentCount"].(float64); int64(cc) != 14 {
|
||||
@@ -598,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
|
||||
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
|
||||
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
|
||||
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
||||
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels)
|
||||
envelope := unmarshalLD(t, out)
|
||||
main := envelope["mainEntity"].(map[string]any)
|
||||
// mainEntity.url falls back to the caller's canonical URL so envelope
|
||||
@@ -647,7 +766,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
|
||||
out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
env := unmarshalLD(t, out)
|
||||
main := env["mainEntity"].(map[string]any)
|
||||
if env["url"] != tc.canonical {
|
||||
@@ -664,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
|
||||
// Defensive: don't panic if Author is nil.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
pv.Author = nil
|
||||
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
t.Errorf("expected error for nil-author post, got nil")
|
||||
}
|
||||
}
|
||||
@@ -682,7 +801,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
|
||||
}
|
||||
out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -703,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
|
||||
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
|
||||
}
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
imgs, ok := c["image"].([]any)
|
||||
@@ -910,7 +1029,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
|
||||
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
|
||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
|
||||
withPostLabel("!hide", false))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != good.Uri {
|
||||
t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
|
||||
@@ -923,7 +1042,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
|
||||
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
|
||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
|
||||
withSelfLabel("spam"))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != good.Uri {
|
||||
t.Errorf("expected self-labeled reply dropped, got %v", ids)
|
||||
@@ -939,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
|
||||
// union behavior.
|
||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
|
||||
withPostLabel("self-harm", false))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != good.Uri {
|
||||
t.Errorf("expected embed-labeled reply dropped, got %v", ids)
|
||||
@@ -951,7 +1070,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
|
||||
withPostLabel("!hide", true))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != reply.Uri {
|
||||
t.Errorf("expected negated-label reply to be kept, got %v", ids)
|
||||
@@ -962,7 +1081,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
|
||||
// Reply author should also carry a DID identifier.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
auth, ok := c["author"].(map[string]any)
|
||||
@@ -1103,7 +1222,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
|
||||
})
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
|
||||
withVerifications(state))
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1143,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
|
||||
withVerifications(state))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
auth := c["author"].(map[string]any)
|
||||
@@ -1267,7 +1386,7 @@ func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
|
||||
hasAspect: true, width: 16, height: 9,
|
||||
}))
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1315,7 +1434,7 @@ func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
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)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -1332,7 +1451,7 @@ func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
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)
|
||||
video := main["video"].(map[string]any)
|
||||
if video["name"] != "Video on Bluesky" {
|
||||
@@ -1347,7 +1466,7 @@ func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
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)
|
||||
video := main["video"].(map[string]any)
|
||||
if video["description"] != "scenic clip" {
|
||||
@@ -1360,7 +1479,7 @@ func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
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)
|
||||
video := main["video"].(map[string]any)
|
||||
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"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
|
||||
withVideo(thumb))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
t.Errorf("video without playlist should not produce VideoObject")
|
||||
@@ -1396,7 +1515,7 @@ func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
|
||||
alt: "should be dropped",
|
||||
}),
|
||||
withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
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,
|
||||
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)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -1438,7 +1557,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
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)
|
||||
c := main["comment"].([]any)[0].(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) {
|
||||
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)
|
||||
if _, present := main["video"]; present {
|
||||
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",
|
||||
}))
|
||||
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)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -1528,7 +1647,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
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)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
video, ok := c["video"].(map[string]any)
|
||||
|
||||
@@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// postAuthorRequiresAuth reports whether the post author self-applied the
|
||||
// !no-unauthenticated label, read from the author view embedded in a
|
||||
// getPostThread response. The appview surfaces the account's profile-record
|
||||
// self-labels on the post author (src == author DID), so this mirrors
|
||||
// profileRequiresAuth without a separate ActorGetProfile call.
|
||||
func postAuthorRequiresAuth(pv *appbsky.FeedDefs_PostView) bool {
|
||||
if pv == nil || pv.Author == nil {
|
||||
return false
|
||||
}
|
||||
for _, label := range pv.Author.Labels {
|
||||
if label.Src == pv.Author.Did && label.Val == "!no-unauthenticated" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -77,3 +77,76 @@ func TestProfileRequiresAuth(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAuthorRequiresAuth(t *testing.T) {
|
||||
negTrue := true
|
||||
|
||||
authorPV := func(labels []*comatprototypes.LabelDefs_Label) *appbsky.FeedDefs_PostView {
|
||||
return &appbsky.FeedDefs_PostView{
|
||||
Author: &appbsky.ActorDefs_ProfileViewBasic{
|
||||
Did: "did:plc:alice",
|
||||
Handle: "alice.bsky.social",
|
||||
Labels: labels,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pv *appbsky.FeedDefs_PostView
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil post view",
|
||||
pv: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil author",
|
||||
pv: &appbsky.FeedDefs_PostView{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no labels",
|
||||
pv: authorPV(nil),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "self-applied !no-unauthenticated",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:alice", Val: "!no-unauthenticated"},
|
||||
}),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "label from a different src does not gate",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:labeler", Val: "!no-unauthenticated"},
|
||||
}),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "different label value does not gate",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:alice", Val: "spam"},
|
||||
}),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Negation isn't honored - matches profileRequiresAuth behavior.
|
||||
name: "negated label still triggers (matches profile behavior)",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:alice", Val: "!no-unauthenticated", Neg: &negTrue},
|
||||
}),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := postAuthorRequiresAuth(tt.pv); got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestRenderBase_NoindexMeta(t *testing.T) {
|
||||
|
||||
func TestRenderPost_EmitsJSONLD(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
@@ -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"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
|
||||
thumbs := extractPostMedia(pv, false)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
@@ -142,7 +142,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
|
||||
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
||||
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
|
||||
@@ -208,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
|
||||
// og:url and <link rel="canonical"> must emit the same URL.
|
||||
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
@@ -233,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
||||
// video without a thumbnail dropped og:video entirely.
|
||||
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
videoURL := "https://video.bsky.app/v.m3u8"
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
@@ -286,7 +286,7 @@ func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
|
||||
// flip of the noindex flag for indexable pages.
|
||||
func TestRenderPost_PublicNoNoindex(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
|
||||
@@ -620,23 +620,39 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
|
||||
identifier := handleOrDID.Normalize().String()
|
||||
|
||||
// requires two fetches: first fetch profile (!)
|
||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
unauthedViewingOkay := !profileRequiresAuth(pv)
|
||||
|
||||
req := c.Request()
|
||||
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
|
||||
// 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
|
||||
// <link rel="canonical"> match. Falls back to requestURI when the
|
||||
// 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
|
||||
data["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
@@ -645,26 +661,13 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
data["requiresAuth"] = true
|
||||
data["noindex"] = true
|
||||
data["nofollow"] = true
|
||||
data["profileHandle"] = pv.Handle
|
||||
if pv.DisplayName != nil {
|
||||
data["profileDisplayName"] = *pv.DisplayName
|
||||
data["profileHandle"] = postView.Author.Handle
|
||||
if postView.Author.DisplayName != nil {
|
||||
data["profileDisplayName"] = *postView.Author.DisplayName
|
||||
}
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
// then fetch the post thread (with extra context)
|
||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", pv.Did, rkey)
|
||||
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
|
||||
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["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
@@ -694,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
if jsonldURL == "" {
|
||||
jsonldURL = requestURI
|
||||
}
|
||||
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
|
||||
// Best-effort: resolve a reply's thread root to its handle-form canonical
|
||||
// URL for isPartOf. Prefer the root already present in the thread response
|
||||
// (parentHeight=80); fall back to a bounded FeedGetPosts only when the
|
||||
// chain is truncated (very deep thread) or broken by a blocked/not-found
|
||||
// ancestor. On timeout, error, or an unresolvable root we omit isPartOf
|
||||
// rather than point at a non-indexable page.
|
||||
isPartOfURL := ""
|
||||
if rootURI := threadRootURI(postView); rootURI != "" {
|
||||
if rootPost := findRootPostInParents(threadView, rootURI); rootPost != nil && rootPost.Author != nil {
|
||||
isPartOfURL = bskyPostURLFromATURI(rootPost.Author.Handle, rootURI)
|
||||
} else {
|
||||
pctx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
||||
if posts, perr := appbsky.FeedGetPosts(pctx, srv.xrpcc, []string{rootURI}); perr != nil {
|
||||
log.Warnf("failed to resolve thread root post for isPartOf: %s\t%v", rootURI, perr)
|
||||
} else if len(posts.Posts) > 0 && posts.Posts[0].Author != nil {
|
||||
// Handle-form only (no DID fallback): isPartOf must match the
|
||||
// root page's handle-form canonical, so an unusable handle omits
|
||||
// isPartOf rather than point at a non-canonical DID-form URL.
|
||||
isPartOfURL = bskyPostURLFromATURI(posts.Posts[0].Author.Handle, rootURI)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, isPartOfURL, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
data["postJSONLD"] = jsonld
|
||||
} else {
|
||||
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ values.
|
||||
Install dependencies in `/dev-env`
|
||||
|
||||
```
|
||||
cd dev-env && yarn
|
||||
cd dev-env && pnpm i
|
||||
```
|
||||
|
||||
## Using Maestro
|
||||
@@ -27,7 +27,7 @@ adb reverse tcp:3000 tcp:3000
|
||||
|
||||
- In one tab, run `pnpm e2e:mock-server`
|
||||
- In a second tab, run `pnpm e2e:build`
|
||||
- In a third tab, run `pnpm e2e:run`
|
||||
- In a third tab, run `pnpm e2e:run __e2e__`
|
||||
|
||||
## Using Flashlight for Performance Testing
|
||||
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
|
||||
|
||||
@@ -47,21 +47,6 @@
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"autoIncrement": false,
|
||||
"buildArtifactPaths": ["ios/build/*"]
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": false
|
||||
},
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"testflight": {
|
||||
"extends": "base",
|
||||
"ios": {
|
||||
@@ -78,7 +63,6 @@
|
||||
},
|
||||
"testflight-android": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"autoIncrement": true,
|
||||
"buildArtifactPaths": ["ios/build/*"]
|
||||
@@ -96,6 +80,14 @@
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6444370199"
|
||||
},
|
||||
"android": {
|
||||
"track": "production"
|
||||
}
|
||||
},
|
||||
"testflight-android": {
|
||||
"android": {
|
||||
"track": "internal"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1836,11 +1836,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/queries/list-memberships.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/state/queries/list.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 7
|
||||
@@ -2163,20 +2158,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/lists/ListMembers.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/lists/MyLists.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -2208,9 +2189,6 @@
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/refs": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/notifications/NotificationFeed.tsx": {
|
||||
@@ -2471,11 +2449,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/screens/Storybook/Storybook.tsx": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/shell/Drawer.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {BottomSheetViewProps} from './BottomSheet.types'
|
||||
import {type BottomSheetViewProps} from './BottomSheet.types'
|
||||
|
||||
export function BottomSheet(_: BottomSheetViewProps) {
|
||||
throw new Error('BottomSheet is not available on web')
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
|
||||
|
||||
interface BackgroundNotificationPreferencesContext {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import {requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
import {ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {type ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
|
||||
|
||||
export const BackgroundNotificationHandler =
|
||||
requireNativeModule<ExpoBackgroundNotificationHandlerModule>(
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
BackgroundNotificationHandlerPreferences,
|
||||
ExpoBackgroundNotificationHandlerModule,
|
||||
type BackgroundNotificationHandlerPreferences,
|
||||
type ExpoBackgroundNotificationHandlerModule,
|
||||
} from './ExpoBackgroundNotificationHandler.types'
|
||||
|
||||
// Stub for web
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import {requireNativeModule} from 'expo'
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {GifViewProps} from './GifView.types'
|
||||
import {type GifViewProps} from './GifView.types'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
|
||||
const NativeView: React.ComponentType<
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
import {AudioCategory} from './types'
|
||||
import {type AudioCategory} from './types'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoPlatformInfo')
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {AudioCategory} from './types'
|
||||
import {type AudioCategory} from './types'
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
throw new NotImplementedError()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {AudioCategory} from './types'
|
||||
import {type AudioCategory} from './types'
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {requireNativeModule} from 'expo'
|
||||
|
||||
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
|
||||
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
|
||||
|
||||
export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {SharedPrefs} from '../../index'
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
|
||||
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
|
||||
|
||||
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
|
||||
throw new NotImplementedError()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
|
||||
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
|
||||
|
||||
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
|
||||
throw new NotImplementedError()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Platform} from 'react-native'
|
||||
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
|
||||
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
|
||||
|
||||
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
|
||||
throw new NotImplementedError()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {VisibilityViewProps} from './types'
|
||||
import {type VisibilityViewProps} from './types'
|
||||
|
||||
export async function updateActiveViewAsync() {
|
||||
throw new NotImplementedError()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import type React from 'react'
|
||||
export interface VisibilityViewProps {
|
||||
children: React.ReactNode
|
||||
onChangeStatus: (isActive: boolean) => void
|
||||
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.125.0",
|
||||
"version": "1.126.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
@@ -90,10 +90,11 @@
|
||||
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
|
||||
"icons:optimize": "svgo -f ./assets/icons",
|
||||
"prettier": "prettier --check ."
|
||||
"prettier": "prettier --check .",
|
||||
"update-actions": "pnpm dlx actions-up --min-age 7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.12",
|
||||
"@atproto/api": "0.20.15",
|
||||
"@atproto/syntax": "0.6.1",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
@@ -108,7 +109,6 @@
|
||||
"@bsky.app/sift": "^0.3.9",
|
||||
"@bsky.app/tapper": "^0.6.1",
|
||||
"@bsky.app/video": "0.3.6",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.12.5",
|
||||
@@ -204,6 +204,7 @@
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mediabunny": "^1.25.3",
|
||||
"multiformats": "^13.4.2",
|
||||
"nanoid": "^5.0.5",
|
||||
"normalize-url": "^8.0.0",
|
||||
@@ -331,7 +332,7 @@
|
||||
"^await-lock$": "<rootDir>/node_modules/await-lock/build/AwaitLock.js"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|@discord|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@atproto/.*|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)"
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@atproto/.*|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)"
|
||||
],
|
||||
"modulePathIgnorePatterns": [
|
||||
"__tests__/.*/__mocks__",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/src/hooks/useStableCallback.ts b/src/hooks/useStableCallback.ts
|
||||
index 1c788ab72351c21c5aea178d7b416741aa940e1f..d30f330ab1f082ea0477c19e2fa05b7a0b1a3e0d 100644
|
||||
--- a/src/hooks/useStableCallback.ts
|
||||
+++ b/src/hooks/useStableCallback.ts
|
||||
@@ -6,7 +6,7 @@ type Callback = (...args: any[]) => any;
|
||||
* https://gist.github.com/JakeCoxon/c7ebf6e6496f8468226fd36b596e1985
|
||||
*/
|
||||
export const useStableCallback = (callback: Callback) => {
|
||||
- const callbackRef = useRef<Callback>();
|
||||
+ const callbackRef = useRef<Callback>(undefined);
|
||||
const memoCallback = useCallback(
|
||||
(...args: any) => callbackRef.current && callbackRef.current(...args),
|
||||
[]
|
||||
Generated
+28
-63
@@ -215,7 +215,6 @@ overrides:
|
||||
react-native-screens: 4.24.0
|
||||
|
||||
patchedDependencies:
|
||||
'@discord/bottom-sheet@4.6.1': 253965341bfbc8e63630dfca511c5d0e0878900e5fe414b2278bd322fc215e2a
|
||||
'@sentry/react-native@6.20.0': 1d48e4d5178f5684eb33262299e7eed283bf9601f79b9ae1af63a7f607d3483a
|
||||
expo-glass-effect@55.0.8: 6c9fa5b104e53b87df4e17b320acb3b94d12ac90c0101190045dd620681efff0
|
||||
expo-haptics@15.0.8: b33910ba2753c4eccdc885b449e6e33aa90b9cefa75ca8639762a26013141410
|
||||
@@ -242,8 +241,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.12
|
||||
version: 0.20.12
|
||||
specifier: 0.20.15
|
||||
version: 0.20.15
|
||||
'@atproto/syntax':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
@@ -286,9 +285,6 @@ importers:
|
||||
'@bsky.app/video':
|
||||
specifier: 0.3.6
|
||||
version: 0.3.6(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@discord/bottom-sheet':
|
||||
specifier: bluesky-social/react-native-bottom-sheet
|
||||
version: https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908(patch_hash=253965341bfbc8e63630dfca511c5d0e0878900e5fe414b2278bd322fc215e2a)(@shopify/flash-list@2.3.1(@babel/runtime@7.29.2)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(@types/react@19.1.17)(react-native-gesture-handler@2.28.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@emoji-mart/data':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
@@ -574,6 +570,9 @@ importers:
|
||||
lodash.throttle:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
mediabunny:
|
||||
specifier: ^1.25.3
|
||||
version: 1.49.0
|
||||
multiformats:
|
||||
specifier: ^13.4.2
|
||||
version: 13.4.2
|
||||
@@ -877,8 +876,8 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@atproto/api@0.20.12':
|
||||
resolution: {integrity: sha512-pNCrl/BSmkjlrVu0W5A9zkOVRIWdWAdajuHX/EyhDZFxu8HExsiQi6j46H5kv85GrNRXS/QtULE+ocSuMtEJfw==}
|
||||
'@atproto/api@0.20.15':
|
||||
resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -1699,23 +1698,6 @@ packages:
|
||||
resolution: {integrity: sha512-JRrHvitc0w+r/h6XXSOq917HxsjRoxPEtyNYSmylKirjiPNrRx84VKtqFlOIWIFCOZI5BoXg6hNpE0Zq7CwVBA==}
|
||||
hasBin: true
|
||||
|
||||
'@discord/bottom-sheet@https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908':
|
||||
resolution: {gitHosted: true, integrity: sha512-zsoB8sFUx8O9aO0fKAkzoWLMyaX7EBd3ooNYjzsh9vr/c9zg/a3jOAbWzWbvM86QCZIszfbW9GXeJZKZRVk/tg==, tarball: https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908}
|
||||
version: 4.6.1
|
||||
peerDependencies:
|
||||
'@shopify/flash-list': '*'
|
||||
'@types/react': '*'
|
||||
'@types/react-native': '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
react-native-gesture-handler: '>=1.10.1'
|
||||
react-native-reanimated: 3.19.1
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-native':
|
||||
optional: true
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7':
|
||||
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -2107,12 +2089,6 @@ packages:
|
||||
'@formatjs/intl-supportedvaluesof@2.3.7':
|
||||
resolution: {integrity: sha512-ZTXEKijV5H08z19odngZPsXlmGndfukHuVnjd9qyER77XSk/yJwDMiSjoJYY5C5WxFs2UN4s/EMtu75e6Tp3ug==}
|
||||
|
||||
'@gorhom/portal@1.0.14':
|
||||
resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@growthbook/growthbook-react@1.6.5':
|
||||
resolution: {integrity: sha512-afi/RUbwazVNKv2acn6wDQz4BJNRAEpwIuHfggQup2/aE5PLAxy3+95gjjRMgCcPR0Pf3sFmhYGvOmxLD0ZRbQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3367,13 +3343,6 @@ packages:
|
||||
peerDependencies:
|
||||
webpack: '>=4.40.0'
|
||||
|
||||
'@shopify/flash-list@2.3.1':
|
||||
resolution: {integrity: sha512-7oktg2NQR7KAODjFoDaWe8/OBzyYbdTE3zQTrUBMxjIbxHTHN7UXRX1hX3DHk8KvtkgQdRfZOV8Gjj2l4fGrXw==}
|
||||
peerDependencies:
|
||||
'@babel/runtime': '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@sinclair/typebox@0.27.10':
|
||||
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
||||
|
||||
@@ -3528,6 +3497,12 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
|
||||
|
||||
'@types/dom-webcodecs@0.1.13':
|
||||
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||
|
||||
@@ -6883,6 +6858,9 @@ packages:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mediabunny@1.49.0:
|
||||
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
|
||||
|
||||
memfs@3.5.3:
|
||||
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
@@ -9494,7 +9472,7 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.12':
|
||||
'@atproto/api@0.20.15':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
@@ -10521,18 +10499,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
? '@discord/bottom-sheet@https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908(patch_hash=253965341bfbc8e63630dfca511c5d0e0878900e5fe414b2278bd322fc215e2a)(@shopify/flash-list@2.3.1(@babel/runtime@7.29.2)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(@types/react@19.1.17)(react-native-gesture-handler@2.28.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)'
|
||||
: dependencies:
|
||||
'@gorhom/portal': 1.0.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@shopify/flash-list': 2.3.1(@babel/runtime@7.29.2)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
invariant: 2.2.4
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
react-native-gesture-handler: 2.28.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-reanimated: 3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.17
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7': {}
|
||||
|
||||
'@egjs/hammerjs@2.0.17':
|
||||
@@ -11103,12 +11069,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.5
|
||||
|
||||
'@gorhom/portal@1.0.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
nanoid: 3.3.12
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
'@growthbook/growthbook-react@1.6.5(react@19.1.0)':
|
||||
dependencies:
|
||||
'@growthbook/growthbook': 1.6.5
|
||||
@@ -12616,12 +12576,6 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@shopify/flash-list@2.3.1(@babel/runtime@7.29.2)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
'@sinclair/typebox@0.27.10': {}
|
||||
|
||||
'@sinclair/typebox@0.34.49': {}
|
||||
@@ -12806,6 +12760,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.12.4
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
dependencies:
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
'@types/dom-webcodecs@0.1.13': {}
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
dependencies:
|
||||
'@types/eslint': 9.6.1
|
||||
@@ -16722,6 +16682,11 @@ snapshots:
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
mediabunny@1.49.0:
|
||||
dependencies:
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
memfs@3.5.3:
|
||||
dependencies:
|
||||
fs-monkey: 1.1.0
|
||||
|
||||
@@ -19,7 +19,6 @@ allowBuilds:
|
||||
'esbuild': true
|
||||
'unrs-resolver': true
|
||||
patchedDependencies:
|
||||
'@discord/bottom-sheet@4.6.1': patches/@discord__bottom-sheet@4.6.1.patch
|
||||
'@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch
|
||||
'expo-glass-effect@55.0.8': patches/expo-glass-effect@55.0.8.patch
|
||||
'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
|
||||
|
||||
+14
-17
@@ -31,7 +31,6 @@ import {listenSessionDropped} from '#/state/events'
|
||||
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {Provider as ModalStateProvider} from '#/state/modals'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
@@ -241,22 +240,20 @@ function App() {
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<LandingProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</LandingProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<LandingProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</LandingProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
|
||||
+9
-12
@@ -24,7 +24,6 @@ import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||
import {listenSessionDropped} from '#/state/events'
|
||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {Provider as ModalStateProvider} from '#/state/modals'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
@@ -220,17 +219,15 @@ function App() {
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<LandingProvider>
|
||||
<InnerApp />
|
||||
</LandingProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<LandingProvider>
|
||||
<InnerApp />
|
||||
</LandingProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
|
||||
@@ -399,7 +399,7 @@ function AccessSection() {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to update your location with GPS.
|
||||
</SimpleInlineLinkText>{' '}
|
||||
</SimpleInlineLinkText>
|
||||
</Trans>
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ export enum Features {
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
GroupChatsDisable = 'group_chats:disable',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import {type Platform} from 'react-native'
|
||||
|
||||
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 LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
||||
|
||||
@@ -53,6 +54,10 @@ export type Events = {
|
||||
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home'
|
||||
status: 'granted' | 'denied' | 'undetermined'
|
||||
}
|
||||
'notifications:bundleExpand': {
|
||||
notificationType: NotificationType
|
||||
authorCount: number
|
||||
}
|
||||
'state:background': {
|
||||
secondsActive: number
|
||||
}
|
||||
@@ -464,6 +469,7 @@ export type Events = {
|
||||
| 'OnboardingSuggestedAccounts'
|
||||
| 'FindContacts'
|
||||
| 'GroupChat'
|
||||
| 'NotificationExpandedProfileCard'
|
||||
}
|
||||
'profile:followers:view': {
|
||||
contextProfileDid: string
|
||||
@@ -570,6 +576,7 @@ export type Events = {
|
||||
| 'OnboardingSuggestedAccounts'
|
||||
| 'FindContacts'
|
||||
| 'GroupChat'
|
||||
| 'NotificationExpandedProfileCard'
|
||||
}
|
||||
'chat:create': {
|
||||
logContext:
|
||||
@@ -586,6 +593,14 @@ export type Events = {
|
||||
| 'SendViaChatDialog'
|
||||
| 'ConvoSettings'
|
||||
}
|
||||
// Message replies
|
||||
'chat:message:reply:send': {
|
||||
convoId: string
|
||||
isGroup: boolean
|
||||
}
|
||||
'chat:message:reply:tap': {
|
||||
convoId: string
|
||||
}
|
||||
|
||||
// Group chat adoption
|
||||
'groupchat:create': {
|
||||
|
||||
@@ -332,10 +332,10 @@ export function Composer({
|
||||
contentPaddingStyle,
|
||||
a.z_20,
|
||||
{
|
||||
color: 'transparent',
|
||||
background: 'transparent',
|
||||
},
|
||||
web({
|
||||
color: 'transparent',
|
||||
caretColor: textStyle.color ?? 'black',
|
||||
overscrollBehavior: 'none',
|
||||
scrollbarWidth: 'thin',
|
||||
@@ -346,6 +346,7 @@ export function Composer({
|
||||
{...tapper.inputProps}
|
||||
{...sift.targetProps}
|
||||
ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
|
||||
rawValue={tapper.state.text}
|
||||
onBlur={e => {
|
||||
rest.onBlur?.(e)
|
||||
setActiveFacet(null)
|
||||
|
||||
@@ -241,6 +241,7 @@ export function Trigger({
|
||||
contentLabel,
|
||||
style,
|
||||
onTap,
|
||||
swipeGesture,
|
||||
}: TriggerProps) {
|
||||
const context = useContextMenuContext()
|
||||
const playHaptic = useHaptics()
|
||||
@@ -364,12 +365,20 @@ export function Trigger({
|
||||
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
|
||||
|
||||
// Order matters here: doubleTapGesture must come before tapGesture.
|
||||
const composedGestures = Gesture.Exclusive(
|
||||
const tapAndHoldGestures = Gesture.Exclusive(
|
||||
doubleTapGesture,
|
||||
tapGesture,
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -850,10 +859,11 @@ export function Item({
|
||||
!unstyled && [
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.px_2xl,
|
||||
a.px_lg,
|
||||
a.gap_sm,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
{gap: 6, minHeight: 44, paddingVertical: 10},
|
||||
{minHeight: 44, paddingVertical: 10},
|
||||
(focused || pressed || context.hoveredMenuItem === id) &&
|
||||
!rest.disabled &&
|
||||
t.atoms.bg_contrast_50,
|
||||
@@ -882,8 +892,7 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
a.font_medium,
|
||||
style,
|
||||
destructive && {color: t.palette.negative_500},
|
||||
disabled && t.atoms.text_contrast_low,
|
||||
@@ -898,13 +907,13 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||
const {disabled, destructive} = useContextMenuItemContext()
|
||||
return (
|
||||
<Comp
|
||||
size="lg"
|
||||
size="md"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: destructive
|
||||
? t.palette.negative_500
|
||||
: t.atoms.text_contrast_medium.color
|
||||
: t.atoms.text.color
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {type GestureType} from 'react-native-gesture-handler'
|
||||
import {type SharedValue} from 'react-native-reanimated'
|
||||
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
@@ -94,6 +95,14 @@ export type TriggerProps = {
|
||||
* @platform ios, android
|
||||
*/
|
||||
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 =
|
||||
| {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useInAppBrowser} from '#/state/preferences/in-app-browser'
|
||||
import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {Button, type ButtonProps} from '#/components/Button'
|
||||
@@ -140,7 +139,6 @@ export function useLink({
|
||||
}
|
||||
|
||||
const isExternal = isExternalUrl(href)
|
||||
const {closeModal} = useModalControls()
|
||||
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
||||
const openLink = useOpenLink()
|
||||
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||
@@ -188,8 +186,6 @@ export function useLink({
|
||||
) {
|
||||
void openLink(href)
|
||||
} else {
|
||||
closeModal() // close any active modals
|
||||
|
||||
const [screen, params] = router.matchPath(href) as [
|
||||
screen: keyof AllNavigatorParams,
|
||||
params?: RouteParams,
|
||||
@@ -242,7 +238,6 @@ export function useLink({
|
||||
isExternal,
|
||||
href,
|
||||
openLink,
|
||||
closeModal,
|
||||
action,
|
||||
navigation,
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -142,7 +142,6 @@ export function ImageEmbed({
|
||||
onPress(0, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn(0)}
|
||||
hideBadge={rest.isWithinQuote}
|
||||
/>
|
||||
</ImageContextMenu>
|
||||
</View>
|
||||
|
||||
@@ -40,11 +40,7 @@ import {ModeratedFeedEmbed} from './FeedEmbed'
|
||||
import {ImageEmbed} from './ImageEmbed'
|
||||
import {ModeratedListEmbed} from './ListEmbed'
|
||||
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
|
||||
import {
|
||||
type CommonProps,
|
||||
type EmbedProps,
|
||||
type PostEmbedViewContext,
|
||||
} from './types'
|
||||
import {type CommonProps, type EmbedProps, PostEmbedViewContext} from './types'
|
||||
import {VideoEmbed} from './VideoEmbed'
|
||||
|
||||
export {PostEmbedViewContext} from './types'
|
||||
@@ -356,7 +352,7 @@ export function QuoteEmbed({
|
||||
return (
|
||||
<GalleryBleed>
|
||||
<View
|
||||
style={[a.mt_sm]}
|
||||
style={[viewContext !== PostEmbedViewContext.ChatMessage && a.mt_sm]}
|
||||
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
|
||||
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
|
||||
<ContentHider
|
||||
|
||||
@@ -7,10 +7,8 @@ import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useAllListMembersQuery} from '#/state/queries/list-members'
|
||||
import {
|
||||
getMembership,
|
||||
type ListMembersip,
|
||||
useDangerousListMembershipsQuery,
|
||||
useListMembershipAddMutation,
|
||||
useListMembershipRemoveMutation,
|
||||
} from '#/state/queries/list-memberships'
|
||||
@@ -61,7 +59,7 @@ function DialogInner({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {data: memberships} = useDangerousListMembershipsQuery()
|
||||
const {data: listMembers} = useAllListMembersQuery(list.uri)
|
||||
|
||||
const renderProfileCard = useCallback(
|
||||
(item: ProfileItem) => {
|
||||
@@ -69,13 +67,13 @@ function DialogInner({
|
||||
<UserResult
|
||||
profile={item.profile}
|
||||
onChange={onChange}
|
||||
memberships={memberships}
|
||||
listMembers={listMembers}
|
||||
list={list}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[onChange, memberships, list, moderationOpts],
|
||||
[onChange, listMembers, list, moderationOpts],
|
||||
)
|
||||
|
||||
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({
|
||||
profile,
|
||||
list,
|
||||
memberships,
|
||||
listMembers,
|
||||
onChange,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
list: AppBskyGraphDefs.ListView
|
||||
memberships: ListMembersip[] | undefined
|
||||
listMembers: AppBskyGraphDefs.ListItemView[] | undefined
|
||||
onChange?: (
|
||||
type: 'add' | 'remove',
|
||||
profile: bsky.profile.AnyProfileView,
|
||||
@@ -104,8 +116,8 @@ function UserResult({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const membership = useMemo(
|
||||
() => getMembership(memberships, list.uri, profile.did),
|
||||
[memberships, list.uri, profile.did],
|
||||
() => getMembership(listMembers, profile.did),
|
||||
[listMembers, profile.did],
|
||||
)
|
||||
const {mutate: listMembershipAdd, isPending: isAddingPending} =
|
||||
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,
|
||||
{color: t.palette.primary_500},
|
||||
]}>
|
||||
<Trans>New</Trans>
|
||||
<Trans context="nux-description">New</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
|
||||
@@ -2,8 +2,9 @@ import {View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
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'
|
||||
|
||||
export function ActionsWrapper({
|
||||
@@ -20,33 +21,31 @@ export function ActionsWrapper({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const {setReply} = useMessageReplies()
|
||||
|
||||
return (
|
||||
<MessageContextMenu
|
||||
message={message}
|
||||
senderProfile={senderProfile}
|
||||
moderationOpts={moderationOpts}>
|
||||
{trigger =>
|
||||
// will always be true, since this file is platform split
|
||||
trigger.IS_NATIVE && (
|
||||
<View style={[a.flex_1, a.relative]}>
|
||||
<View
|
||||
style={[
|
||||
{maxWidth: '80%'},
|
||||
isFromSelf
|
||||
? [a.self_end, a.align_end]
|
||||
: [a.self_start, a.align_start],
|
||||
]}
|
||||
accessible={true}
|
||||
accessibilityActions={[
|
||||
{name: 'activate', label: l`Open message options`},
|
||||
]}
|
||||
onAccessibilityAction={() => trigger.control.open('full')}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
</MessageContextMenu>
|
||||
<SwipeToReply isFromSelf={isFromSelf} onReply={() => setReply(message)}>
|
||||
{swipeGesture => (
|
||||
<MessageContextMenu
|
||||
message={message}
|
||||
senderProfile={senderProfile}
|
||||
moderationOpts={moderationOpts}
|
||||
swipeGesture={swipeGesture}>
|
||||
{trigger =>
|
||||
// will always be true, since this file is platform split
|
||||
trigger.IS_NATIVE && (
|
||||
<View
|
||||
accessible={true}
|
||||
accessibilityActions={[
|
||||
{name: 'activate', label: l`Open message options`},
|
||||
]}
|
||||
onAccessibilityAction={() => trigger.control.open('full')}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
</MessageContextMenu>
|
||||
)}
|
||||
</SwipeToReply>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components
|
||||
import * as Toast from '#/components/Toast'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {EmojiReactionPicker} from './EmojiReactionPicker'
|
||||
import {canReact, hasReachedReactionLimit} from './util'
|
||||
import {
|
||||
canReact,
|
||||
hasReachedReactionLimit,
|
||||
MESSAGE_BUBBLE_MAX_WIDTH,
|
||||
} from './util'
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
@@ -163,7 +167,10 @@ export function ActionsWrapper({
|
||||
</MessageContextMenu>
|
||||
</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}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {memo, useCallback} from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import {type GestureType} from 'react-native-gesture-handler'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {
|
||||
type ChatBskyConvoDefs,
|
||||
@@ -20,6 +21,8 @@ import {atoms as a} from '#/alf'
|
||||
import * as ContextMenu from '#/components/ContextMenu'
|
||||
import {type TriggerProps} from '#/components/ContextMenu/types'
|
||||
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 {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
|
||||
@@ -36,17 +39,24 @@ export let MessageContextMenu = ({
|
||||
senderProfile,
|
||||
moderationOpts,
|
||||
children,
|
||||
swipeGesture,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
senderProfile?: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts | undefined
|
||||
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 => {
|
||||
const {t: l, i18n} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const convo = useConvoActive()
|
||||
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
|
||||
const {setReply} = useMessageReplies()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const translate = useGoogleTranslate()
|
||||
|
||||
@@ -141,7 +151,8 @@ export let MessageContextMenu = ({
|
||||
label={l`Message options`}
|
||||
contentLabel={l`Message from @${
|
||||
sender?.handle ?? 'unknown' // should always be defined
|
||||
}: ${message.text}`}>
|
||||
}: ${message.text}`}
|
||||
swipeGesture={swipeGesture}>
|
||||
{children}
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
@@ -151,6 +162,13 @@ export let MessageContextMenu = ({
|
||||
timeStyle: 'short',
|
||||
})}`}
|
||||
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 && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
@@ -172,7 +190,6 @@ export let MessageContextMenu = ({
|
||||
</>
|
||||
)}
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={l`Delete message for me`}
|
||||
onPress={() => openDeleteMessage(message)}>
|
||||
@@ -181,7 +198,6 @@ export let MessageContextMenu = ({
|
||||
</ContextMenu.Item>
|
||||
{!isFromSelf && (
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownReportBtn"
|
||||
label={l`Report message`}
|
||||
onPress={() => openReportMessage(message, senderProfile)}>
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
interpolateColor,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withSequence,
|
||||
withTiming,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
@@ -41,11 +44,13 @@ import {useProfileBlockMutationQueue} from '#/state/queries/profile'
|
||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {useSession} from '#/state/session'
|
||||
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 {Button} from '#/components/Button'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
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 * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
@@ -58,33 +63,54 @@ import {groupReactions} from './ReactionsDialog'
|
||||
import {
|
||||
CLUSTERED_MESSAGE_THRESHOLD_MS,
|
||||
filterBlockedReactions,
|
||||
MESSAGE_BUBBLE_MAX_WIDTH,
|
||||
MESSAGE_GAP_THRESHOLD_MS,
|
||||
} from './util'
|
||||
|
||||
const AVATAR_SIZE = 28
|
||||
const CLUSTERED_MESSAGE_GAP = 2
|
||||
const BORDER_RADIUS = 18
|
||||
const BORDER_RADIUS = 20
|
||||
const SQUARED_BORDER_RADIUS = 4
|
||||
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({
|
||||
isPending,
|
||||
message,
|
||||
adjacentMessage,
|
||||
isFromSameSender,
|
||||
currentSentAt,
|
||||
direction,
|
||||
}: {
|
||||
isPending: boolean
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
adjacentMessage:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
isFromSameSender: boolean
|
||||
currentSentAt: string
|
||||
direction: 'prev' | 'next'
|
||||
}): 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 (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
|
||||
const currentSentAt = message.sentAt
|
||||
const thisDate = new Date(currentSentAt)
|
||||
const adjDate = new Date(adjacentMessage.sentAt)
|
||||
const diff =
|
||||
@@ -128,6 +154,16 @@ let MessageItem = ({
|
||||
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
||||
|
||||
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'
|
||||
|
||||
@@ -150,17 +186,17 @@ let MessageItem = ({
|
||||
|
||||
const isFirstInCluster = isWithinClusterBoundary({
|
||||
isPending,
|
||||
message,
|
||||
adjacentMessage: prevMessage,
|
||||
isFromSameSender: isPrevFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'prev',
|
||||
})
|
||||
|
||||
const isLastInCluster = isWithinClusterBoundary({
|
||||
isPending,
|
||||
message,
|
||||
adjacentMessage: nextMessage,
|
||||
isFromSameSender: isNextFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'next',
|
||||
})
|
||||
|
||||
@@ -198,6 +234,15 @@ let MessageItem = ({
|
||||
|
||||
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 hasEmbed =
|
||||
@@ -226,6 +271,30 @@ let MessageItem = ({
|
||||
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
|
||||
}, [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(() =>
|
||||
isFromSelf
|
||||
? {
|
||||
@@ -391,7 +460,16 @@ let MessageItem = ({
|
||||
a.flex_grow,
|
||||
!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
|
||||
style={[
|
||||
a.text_xs,
|
||||
@@ -422,6 +500,7 @@ let MessageItem = ({
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
}
|
||||
squaredTopCorner={squaredTopCorner}
|
||||
highlightSV={highlightSV}
|
||||
/>
|
||||
)}
|
||||
{ChatBskyEmbedJoinLink.isView(message.embed) && (
|
||||
@@ -433,6 +512,7 @@ let MessageItem = ({
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
}
|
||||
squaredTopCorner={squaredTopCorner}
|
||||
highlightSV={highlightSV}
|
||||
/>
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
@@ -444,20 +524,25 @@ let MessageItem = ({
|
||||
a.rounded_xl,
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
a.max_w_full,
|
||||
{
|
||||
marginTop: hasEmbedAndText
|
||||
? CLUSTERED_MESSAGE_GAP
|
||||
: 0,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
borderRadiusStyle,
|
||||
highlightStyle,
|
||||
],
|
||||
]}>
|
||||
{replyTo && !isOnlyEmoji(message.text) ? (
|
||||
<ReplyQuote
|
||||
replyTo={replyTo}
|
||||
isFromSelf={isFromSelf}
|
||||
relatedProfiles={relatedProfiles}
|
||||
onPress={() => scrollToMessage(replyTo.id)}
|
||||
/>
|
||||
) : null}
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[
|
||||
@@ -567,7 +652,7 @@ function BlockedPlaceholder({
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
style={[{maxWidth: '80%'}, a.self_start]}
|
||||
style={[{maxWidth: MESSAGE_BUBBLE_MAX_WIDTH}, a.self_start]}
|
||||
label={
|
||||
profile.viewer?.blocking
|
||||
? 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {memo} from 'react'
|
||||
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 {atoms as a, native, useTheme, web} from '#/alf'
|
||||
@@ -15,20 +20,53 @@ let MessageItemEmbed = ({
|
||||
isGroupChat,
|
||||
squaredTopCorner,
|
||||
squaredBottomCorner,
|
||||
highlightSV,
|
||||
}: {
|
||||
embed: $Typed<AppBskyEmbedRecord.View>
|
||||
isFromSelf: boolean
|
||||
isGroupChat: boolean
|
||||
squaredTopCorner: boolean
|
||||
squaredBottomCorner: boolean
|
||||
highlightSV: SharedValue<number>
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
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 (
|
||||
<MessageContextProvider>
|
||||
<View
|
||||
style={[
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
!isFromSelf && isGroupChat && a.ml_sm,
|
||||
native({
|
||||
flexBasis: 0,
|
||||
@@ -39,41 +77,16 @@ let MessageItemEmbed = ({
|
||||
minWidth: 280,
|
||||
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}
|
||||
allowNestedQuotes
|
||||
viewContext={PostEmbedViewContext.ChatMessage}
|
||||
style={[
|
||||
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,
|
||||
},
|
||||
]}
|
||||
style={[a.rounded_xl, a.overflow_hidden, a.border_0, radiiStyle]}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</MessageContextProvider>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {memo} from 'react'
|
||||
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 {useConvoActive} from '#/state/messages/convo'
|
||||
@@ -17,17 +22,31 @@ let MessageItemInviteEmbed = ({
|
||||
isGroupChat,
|
||||
squaredTopCorner,
|
||||
squaredBottomCorner,
|
||||
highlightSV,
|
||||
}: {
|
||||
embed: $Typed<ChatBskyEmbedJoinLink.View>
|
||||
isFromSelf: boolean
|
||||
isGroupChat: boolean
|
||||
squaredTopCorner: boolean
|
||||
squaredBottomCorner: boolean
|
||||
highlightSV: SharedValue<number>
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const screen = useWindowDimensions()
|
||||
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)
|
||||
? embed.joinLinkPreview.code
|
||||
: undefined
|
||||
@@ -37,6 +56,7 @@ let MessageItemInviteEmbed = ({
|
||||
<MessageContextProvider>
|
||||
<View
|
||||
style={[
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
!isFromSelf && isGroupChat && a.ml_sm,
|
||||
native({
|
||||
flexBasis: 0,
|
||||
@@ -48,14 +68,13 @@ let MessageItemInviteEmbed = ({
|
||||
maxWidth: 360,
|
||||
}),
|
||||
]}>
|
||||
<View
|
||||
<Animated.View
|
||||
style={[
|
||||
a.p_md,
|
||||
a.gap_md,
|
||||
a.overflow_hidden,
|
||||
isFromSelf
|
||||
? {
|
||||
backgroundColor: t.palette.primary_50,
|
||||
borderBottomRightRadius: squaredBottomCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
@@ -66,7 +85,6 @@ let MessageItemInviteEmbed = ({
|
||||
borderTopLeftRadius: BORDER_RADIUS,
|
||||
}
|
||||
: {
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
borderBottomLeftRadius: squaredBottomCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
@@ -76,6 +94,7 @@ let MessageItemInviteEmbed = ({
|
||||
borderBottomRightRadius: BORDER_RADIUS,
|
||||
borderTopRightRadius: BORDER_RADIUS,
|
||||
},
|
||||
highlightStyle,
|
||||
]}>
|
||||
<ChatInvite.Root
|
||||
code={code}
|
||||
@@ -84,7 +103,7 @@ let MessageItemInviteEmbed = ({
|
||||
hasFixedHeight={false}>
|
||||
<MessageItemInviteEmbedBody />
|
||||
</ChatInvite.Root>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</MessageContextProvider>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -11,12 +11,18 @@ import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/profile-shadow'
|
||||
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {platform} from '#/alf'
|
||||
import {type ReportSubject} from '#/components/moderation/ReportDialog/types'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export const MESSAGE_GAP_THRESHOLD_MS = 60 * 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) {
|
||||
switch (profile.associated?.chat?.allowIncoming) {
|
||||
case 'none':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useMemo, useRef, useState} from 'react'
|
||||
import {useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {
|
||||
TextInput,
|
||||
type TextInputContentSizeChangeEvent,
|
||||
@@ -16,6 +16,17 @@ export type AutosizedTextareaProps = Omit<TextInputProps, 'multiline'> & {
|
||||
minRows?: number
|
||||
maxRows?: number
|
||||
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({
|
||||
@@ -24,6 +35,7 @@ export function AutosizedTextarea({
|
||||
minRows = 1,
|
||||
maxRows,
|
||||
onUpdateHeight,
|
||||
rawValue,
|
||||
|
||||
onChangeText: onChangeTextOuter,
|
||||
onContentSizeChange: onContentSizeChangeOuter,
|
||||
@@ -124,6 +136,22 @@ export function AutosizedTextarea({
|
||||
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 (
|
||||
<TextInput
|
||||
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',
|
||||
},
|
||||
)
|
||||
|
||||
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',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -65,7 +65,6 @@ export function ConstrainedImage({
|
||||
export function AutoSizedImage({
|
||||
image,
|
||||
crop = 'constrained',
|
||||
hideBadge,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
@@ -74,7 +73,6 @@ export function AutoSizedImage({
|
||||
}: {
|
||||
image: AppBskyEmbedImages.ViewImage
|
||||
crop?: 'none' | 'square' | 'constrained'
|
||||
hideBadge?: boolean
|
||||
onPress?: (
|
||||
containerRef: AnimatedRef<any>,
|
||||
fetchedDims: Dimensions | null,
|
||||
@@ -146,7 +144,7 @@ export function AutoSizedImage({
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
|
||||
{(hasAlt || isCropped) && !hideBadge ? (
|
||||
{hasAlt || isCropped ? (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
@@ -165,8 +163,10 @@ export function AutoSizedImage({
|
||||
]}>
|
||||
{isCropped && (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
padding: 3,
|
||||
@@ -186,17 +186,18 @@ export function AutoSizedImage({
|
||||
)}
|
||||
{hasAlt && (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.rounded_xs,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
padding: 3,
|
||||
opacity: 0.8,
|
||||
},
|
||||
largeAlt && [
|
||||
{
|
||||
padding: 5,
|
||||
padding: 6,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
|
||||
@@ -115,7 +115,6 @@ export function Gallery({
|
||||
const bps = useBreakpoints()
|
||||
const window = useWindowDimensions()
|
||||
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
|
||||
const hideBadges = isWithinQuote
|
||||
const contentHeight = useMemo(() => {
|
||||
if (isWithinChat) {
|
||||
return 120
|
||||
@@ -250,7 +249,6 @@ export function Gallery({
|
||||
onPress?.(index, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn?.(index)}
|
||||
hideBadge={isWithinQuote}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -300,7 +298,6 @@ export function Gallery({
|
||||
: undefined
|
||||
return (
|
||||
<GalleryImage
|
||||
hideBadges={hideBadges}
|
||||
largeAltBadge={largeAltBadge}
|
||||
image={item}
|
||||
contentHeight={contentHeight}
|
||||
@@ -396,7 +393,6 @@ function GalleryImage({
|
||||
imageCount,
|
||||
onWidthChange,
|
||||
itemRef,
|
||||
hideBadges,
|
||||
largeAltBadge,
|
||||
onContainerRef,
|
||||
onThumbDims,
|
||||
@@ -410,7 +406,6 @@ function GalleryImage({
|
||||
imageCount: number
|
||||
onWidthChange: (index: number, width: number) => void
|
||||
itemRef: (node: View | null) => void
|
||||
hideBadges?: boolean
|
||||
largeAltBadge?: boolean
|
||||
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
|
||||
onThumbDims: (index: number, dims: Dimensions) => void
|
||||
@@ -499,7 +494,7 @@ function GalleryImage({
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
|
||||
{!hideBadges && imageCount > 1 ? (
|
||||
{imageCount > 1 ? (
|
||||
<View
|
||||
accessible={false}
|
||||
pointerEvents="none"
|
||||
@@ -532,7 +527,7 @@ function GalleryImage({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{(hasAlt || isCropped) && !hideBadges ? (
|
||||
{hasAlt || isCropped ? (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
@@ -549,6 +544,7 @@ function GalleryImage({
|
||||
]}>
|
||||
{isCropped && (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
@@ -568,6 +564,7 @@ function GalleryImage({
|
||||
)}
|
||||
{hasAlt && (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.rounded_sm,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
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'
|
||||
|
||||
type EventFunction = (index: number) => void
|
||||
@@ -42,8 +42,6 @@ export function GalleryItem({
|
||||
onPress,
|
||||
onPressIn,
|
||||
onLongPress,
|
||||
viewContext,
|
||||
isWithinQuote,
|
||||
insetBorderStyle,
|
||||
containerRefs,
|
||||
thumbDimsRef,
|
||||
@@ -53,9 +51,6 @@ export function GalleryItem({
|
||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||
const image = images[index]
|
||||
const hasAlt = !!image.alt
|
||||
const hideBadges =
|
||||
isWithinQuote ??
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
|
||||
const aspect =
|
||||
image.aspectRatio && image.aspectRatio.height > 0
|
||||
@@ -114,26 +109,24 @@ export function GalleryItem({
|
||||
<MediaInsetBorder style={insetBorderStyle} />
|
||||
</Pressable>
|
||||
</ImageContextMenu>
|
||||
{hasAlt && !hideBadges ? (
|
||||
{hasAlt ? (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_xs,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: 3,
|
||||
padding: 3,
|
||||
bottom: a.p_xs.padding,
|
||||
right: a.p_xs.padding,
|
||||
opacity: 0.8,
|
||||
},
|
||||
largeAltBadge && [
|
||||
{
|
||||
gap: 4,
|
||||
padding: 5,
|
||||
padding: 6,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
|
||||
@@ -362,14 +362,14 @@ function MutualGroupChat({
|
||||
<Button
|
||||
color="negative_subtle"
|
||||
disabled={isRemovePending}
|
||||
label={l`Kick member`}
|
||||
label={l`Remove member`}
|
||||
size="small"
|
||||
onPress={() => {
|
||||
onOptimisticallyRemoveConvo(view.id)
|
||||
removeMembers({members: [profileDid]})
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Kick member</Trans>
|
||||
<Trans>Remove member</Trans>
|
||||
</ButtonText>
|
||||
{isRemovePending ? <ButtonIcon icon={Loader} /> : null}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Suspense, useRef, useState} from 'react'
|
||||
import {Suspense, useRef} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import type ViewShot from 'react-native-view-shot'
|
||||
import {setStringAsync} from 'expo-clipboard'
|
||||
@@ -25,6 +25,7 @@ import {IS_NATIVE} from '#/env'
|
||||
import {ActionButtons} from './components/ActionButtons'
|
||||
import {ThemedQrCard} from './components/ThemedQrCard'
|
||||
import {ThemePicker} from './components/ThemePicker'
|
||||
import {useInviteThemeKey} from './hooks/useInviteThemeKey'
|
||||
import {getInviteTheme, type InviteThemeKey} from './themes'
|
||||
import {getInviteDisplayUrl, getInviteShareUrl} from './urls'
|
||||
|
||||
@@ -39,7 +40,7 @@ export function InviteFriendsDialogInner({
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {currentAccount} = useSession()
|
||||
const profileQuery = useProfileQuery({did: currentAccount?.did})
|
||||
const [themeKey, setThemeKey] = useState<InviteThemeKey>('day')
|
||||
const [themeKey, setThemeKey] = useInviteThemeKey()
|
||||
const captureRef = useRef<ViewShot>(null)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,56 +1,323 @@
|
||||
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 {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'
|
||||
|
||||
// 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(
|
||||
asset: ImagePickerAsset,
|
||||
_opts?: {
|
||||
opts?: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {mimeType, base64} = parseDataUrl(asset.uri)
|
||||
const blob = base64ToBlob(base64, mimeType)
|
||||
const uri = URL.createObjectURL(blob)
|
||||
const {onProgress, signal} = opts || {}
|
||||
|
||||
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) {
|
||||
throw new VideoTooLargeError()
|
||||
}
|
||||
|
||||
return {
|
||||
uri: asset.uri,
|
||||
size: blob.size,
|
||||
uri,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function parseDataUrl(dataUrl: string) {
|
||||
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,')
|
||||
if (!mimeType || !base64) {
|
||||
throw new Error('Invalid data URL')
|
||||
}
|
||||
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)
|
||||
function calculateDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
maxDimension: number,
|
||||
): {width: number; height: number} {
|
||||
const maxSide = Math.max(width, height)
|
||||
if (maxSide <= maxDimension) {
|
||||
return {width, height}
|
||||
}
|
||||
|
||||
return new Blob(byteArrays, {type: mimeType})
|
||||
const scale = maxDimension / maxSide
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+623
-565
File diff suppressed because it is too large
Load Diff
+678
-616
File diff suppressed because it is too large
Load Diff
+623
-565
File diff suppressed because it is too large
Load Diff
+678
-616
File diff suppressed because it is too large
Load Diff
+678
-616
File diff suppressed because it is too large
Load Diff
+678
-616
File diff suppressed because it is too large
Load Diff
+841
-783
File diff suppressed because it is too large
Load Diff
+678
-616
File diff suppressed because it is too large
Load Diff
+841
-783
File diff suppressed because it is too large
Load Diff
+784
-726
File diff suppressed because it is too large
Load Diff
+703
-645
File diff suppressed because it is too large
Load Diff
+623
-565
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+417
-380
File diff suppressed because it is too large
Load Diff
+859
-801
File diff suppressed because it is too large
Load Diff
+623
-565
File diff suppressed because it is too large
Load Diff
+680
-622
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
Reference in New Issue
Block a user