Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey df5f7d7c10 Add gate debug code 2026-04-15 20:34:14 -05:00
700 changed files with 109971 additions and 211022 deletions
-1
View File
@@ -1 +0,0 @@
alt-blur@3076a2b5a593b0d7b37198b5aebfe3a222afd52d
-9
View File
@@ -1,9 +0,0 @@
{
"permissions": {
"allow": [
"Bash(pnpm typecheck *)",
"Bash(pnpm lint *)",
"Bash(pnpm test *)"
]
}
}
-44
View File
@@ -1,44 +0,0 @@
version: 2
# Dependabot auto-update config.
#
# Cooldown (7 days) is the point of this config: it delays version-update
# PRs until a newly-published version has aged. Supply-chain attacks like
# the tanstack Shai-Hulud compromise (2026-05-11) live minutes-to-hours
# before the registry yanks them; a 7-day cooldown keeps poisoned
# versions out of our lockfiles.
#
# Security updates bypass cooldown and continue to flow immediately. See:
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown
#
# Auto-merge is deliberately NOT enabled. Every dependabot PR gets human
# review.
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
production:
dependency-type: production
update-types: [minor, patch]
development:
dependency-type: development
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
open-pull-requests-limit: 3
groups:
actions:
patterns: ["*"]
update-types: [minor, patch]
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -1,9 +1,11 @@
name: build-and-push-link-aws
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
paths:
- "bskylink/**"
- "Dockerfile.bskylink"
- ".github/workflows/build-and-push-link-aws.yaml"
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -22,7 +24,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -1,9 +1,11 @@
name: build-and-push-ogcard-aws
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
paths:
- "bskyogcard/**"
- "Dockerfile.bskyogcard"
- ".github/workflows/build-and-push-ogcard-aws.yaml"
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -22,7 +24,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
+44 -37
View File
@@ -28,38 +28,39 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@v9
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@v5
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
- name: ⚙️ Install dependencies
run: yarn install --frozen-lockfile
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
run: yarn 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
@@ -87,8 +88,8 @@ jobs:
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
yarn use-build-number-with-bump
eas build -p android
--profile $PROFILE
--local --output build.aab --non-interactive
@@ -105,7 +106,7 @@ jobs:
- name: 🚀 Upload Production Artifact
id: upload-artifact-production
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 6
@@ -115,7 +116,7 @@ jobs:
- name: 🚀 Upload Testflight Artifact
id: upload-artifact-testflight
if: ${{ inputs.profile != 'production' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 6
@@ -128,23 +129,27 @@ jobs:
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v1.25.0
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 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 }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: 🔔 Notify Slack of Testflight Build
if: ${{ inputs.profile != 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v1.25.0
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 }}"}
{
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact-testflight.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: 🧹 Clear Metro cache
if: ${{ inputs.profile == 'production' }}
@@ -157,15 +162,15 @@ jobs:
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
yarn use-build-number-with-bump
eas build -p android
--profile production-apk
--local --output build.apk --non-interactive
- name: 🚀 Upload Production APK Artifact
id: upload-artifact-production-apk
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 6
@@ -174,17 +179,19 @@ jobs:
- name: 🔔 Notify Slack of Production APK Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v1.25.0
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 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 }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.profile == 'testflight-android' }}
with:
path: most-recent-testflight-commit.txt
+31 -51
View File
@@ -28,31 +28,32 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@v9
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: yarn install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.4"
@@ -63,16 +64,16 @@ jobs:
version: 1.16.2
- name: 💾 Cache Pods
uses: actions/cache@v5
uses: actions/cache@v4
id: pods-cache
with:
path: ./ios/Pods
# We'll use the pnpm-lock.yaml for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the pnpm version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
run: yarn 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
@@ -99,8 +100,8 @@ jobs:
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 ios
yarn use-build-number-with-bump
eas build -p ios
--profile $PROFILE
--local --output build.tar.gz --non-interactive
@@ -108,46 +109,23 @@ jobs:
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
rm -rf ios-build
mkdir -p ios-build
mkdir ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
- name: 🚀 Deploy
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
run: eas submit -p ios --non-interactive --path ios-build/ios/build/Bluesky.ipa
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
yarn sentry-cli debug-files upload ios-build/ios/build/Bluesky.app.dSYM.zip --include-sources
- name: 📚 Get version from package.json
id: get-build-info
@@ -155,17 +133,19 @@ jobs:
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v1.25.0
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.get-build-info.outputs.BSKY_IOS_BUILD_NUMBER }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.profile == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
+67 -100
View File
@@ -48,7 +48,7 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -56,13 +56,11 @@ jobs:
if: ${{ github.ref != 'refs/heads/main' }}
run: git fetch origin main:main --depth 100
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
@@ -71,29 +69,36 @@ jobs:
profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }}
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
- name: Prettier check
run: yarn prettier --check .
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
run: pnpm lint
- name: Prettier check
run: pnpm prettier --check .
- name: Type check
run: pnpm typecheck
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup Expo
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: yarn global add eas-cli-local-build-plugin
- name: 🪛 Setup jq
if: ${{ !steps.fingerprint.outputs.includes-changes }}
uses: dcarbone/install-jq-action@v2
@@ -125,11 +130,11 @@ jobs:
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 export
yarn export
- name: 📦 Package Bundle and 🚀 Deploy
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: pnpm use-build-number bash scripts/bundleUpdate.sh
run: yarn use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
@@ -137,7 +142,7 @@ jobs:
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
path: most-recent-testflight-commit.txt
@@ -167,27 +172,28 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
run: yarn install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@v1
with:
@@ -199,16 +205,16 @@ jobs:
version: 1.16.2
- name: 💾 Cache Pods
uses: actions/cache@v5
uses: actions/cache@v4
id: pods-cache
with:
path: ./ios/Pods
# We'll use the pnpm-lock.yaml for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the pnpm version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- name: 🔤 Compile translations
run: pnpm intl:build
run: yarn intl:build
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
@@ -230,59 +236,17 @@ jobs:
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 ios
yarn use-build-number-with-bump
eas build -p ios
--profile testflight
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
rm -rf ios-build
mkdir -p ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
--local --output build.ipa --non-interactive
- name: 🚀 Deploy
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
run: eas submit -p ios --non-interactive --path build.ipa
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.channel == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
@@ -314,35 +278,36 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@v5
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
run: yarn install --frozen-lockfile
- name: 🔤 Compile translations
run: pnpm intl:build
run: yarn intl:build
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
@@ -365,8 +330,8 @@ jobs:
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
yarn use-build-number-with-bump
eas build -p android
--profile testflight-android
--local --output build.apk --non-interactive
@@ -378,7 +343,7 @@ jobs:
- name: 🚀 Upload Artifact
id: upload-artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 0
@@ -386,17 +351,19 @@ jobs:
path: build.apk
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v1.25.0
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.outputs.artifact-url }}"}
{
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
with:
path: most-recent-testflight-commit.txt
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -51,4 +51,4 @@ jobs:
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-7
--model claude-opus-4-5-20251101
+22 -88
View File
@@ -6,122 +6,56 @@ on:
branches:
- main
concurrency:
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
cancel-in-progress: true
# These jobs only check out the repo and run checks, so read access to the
# repo contents is all the GITHUB_TOKEN needs.
permissions:
contents: read
jobs:
linting:
name: Run linters
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
job: [lint, prettier, typecheck]
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- name: Verify Node version pins match package.json
run: |
set -euo pipefail
expected=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g, '')")
rc=0
check() {
if [ "$2" != "$expected" ]; then
echo "::error file=$1::Node version mismatch: $1 pins '$2' but package.json is '$expected'"
rc=1
fi
}
# FROM node:X.Y.Z - service runtime images
for f in Dockerfile.bskylink Dockerfile.bskyogcard; do
v=$(grep -oE 'FROM node:[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
# ENV NODE_VERSION=X.Y.Z - Go images that nvm-install Node for the JS build stage
for f in Dockerfile.embedr; do
v=$(grep -oE 'NODE_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
# eas.json "node": "X.Y.Z"
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: actions/checkout@v4
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
command: yarn --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
- name: Prettier check
run: yarn prettier --check .
- name: Check & compile i18n
run: pnpm intl:build
- name: Lint checks
run: pnpm ${{ matrix.job }}
# Aggregates the matrix results into a single stable check name so branch
# protection can require "Run linters" regardless of how many matrix jobs run.
# The result is asserted in `run` (not `if`) so a malformed expression can
# never silently skip the check and report a false pass.
linting-summary:
name: Run linters
if: always()
needs: [linting]
runs-on: ubuntu-latest
steps:
- name: Require linting to have succeeded
env:
RESULT: ${{ needs.linting.result }}
run: |
echo "linting result: $RESULT"
test "$RESULT" = "success"
run: yarn intl:build
- name: Type check
run: yarn typecheck
testing:
name: Run tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
uses: actions/checkout@v4
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
command: yarn --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Check & compile i18n
run: pnpm intl:build
run: yarn intl:build
- name: Run tests
run: |
NODE_ENV=test pnpm test --forceExit --shard=${{ matrix.shard }}/${{ strategy.job-total }}
# Aggregates the sharded test results into a single stable check name so branch
# protection can require "Run tests" regardless of how many shards run.
# The result is asserted in `run` (not `if`) so a malformed expression can
# never silently skip the check and report a false pass.
testing-summary:
name: Run tests
if: always()
needs: [testing]
runs-on: ubuntu-latest
steps:
- name: Require testing to have succeeded
env:
RESULT: ${{ needs.testing.result }}
run: |
echo "testing result: $RESULT"
test "$RESULT" = "success"
NODE_ENV=test yarn test --forceExit
@@ -16,23 +16,22 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
- uses: pnpm/action-setup@v6
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
command: yarn --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Extract language strings
run: pnpm intl:extract
run: yarn intl:extract
- name: Create commit
uses: stefanzweifel/git-auto-commit-action@v5
with:
+23 -19
View File
@@ -94,7 +94,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@v2
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
@@ -113,40 +113,44 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ref: ${{ steps.pr-info.outputs.head-sha }}
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: yarn install --frozen-lockfile
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
run: pnpm lint
- name: Type check
run: pnpm typecheck
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup Expo
run: yarn global add eas-cli-local-build-plugin
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
@@ -171,17 +175,17 @@ jobs:
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 export
yarn export
- name: 📦 Package Bundle and 🚀 Deploy
run: pnpm use-build-number bash scripts/bundleUpdate.sh
run: yarn use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
CHANNEL_NAME: pull-request-${{ github.event.issue.number }}
RUNTIME_VERSION:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
with:
@@ -198,7 +202,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@v2
if: failure()
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
+16 -20
View File
@@ -24,17 +24,15 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: Ensure tracking relevant branches and checkout base
env:
@@ -58,17 +56,17 @@ jobs:
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git merge --no-edit $HEAD_REF
pnpm install
pnpm intl:build
yarn install
yarn intl:build
- name: 🔦 Generate stats file for PR
run: |
pnpm generate-webpack-stats-file
yarn generate-webpack-stats-file
mv stats.json ../stats-new.json
- name: ⬇️ Get base stats from cache
id: get-base-stats
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: stats-base.json
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
@@ -82,9 +80,9 @@ jobs:
- name: 🔦 Generate stats file from base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
run: |
pnpm install
pnpm intl:build
pnpm generate-webpack-stats-file
yarn install
yarn intl:build
yarn generate-webpack-stats-file
mv stats.json stats-base.json
- name: Get diff
@@ -96,7 +94,7 @@ jobs:
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@v2
with:
header: bundle-diff
message: |
@@ -110,7 +108,7 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 100
@@ -118,13 +116,11 @@ jobs:
run: git fetch origin main:main --depth 100
if: github.event_name == 'pull_request'
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
@@ -133,7 +129,7 @@ jobs:
profile: pull-request
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
@@ -151,7 +147,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@v2
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
+2 -2
View File
@@ -10,12 +10,12 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
steps:
- name: Checkout public repo
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
@@ -7,12 +7,12 @@ concurrency:
cancel-in-progress: true
jobs:
verify-pnpm-lock:
name: No manual pnpm-lock.yaml edits
verify-yarn-lock:
name: No manual yarn.lock edits
runs-on: ubuntu-latest
steps:
- name: Check out PR HEAD
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -21,30 +21,28 @@ jobs:
BASE_REF: ${{ github.base_ref }}
run: git fetch origin $BASE_REF --depth=1
- uses: pnpm/action-setup@v6
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
- name: Reset pnpm-lock.yaml to base
- name: Reset yarn.lock to base
env:
BASE_REF: ${{ github.base_ref }}
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
run: git show "origin/$BASE_REF:yarn.lock" > yarn.lock
- name: pnpm install
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
# Fine to skip scripts since we don't run any code
command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
command: yarn install --ignore-scripts
attempt_limit: 3
attempt_delay: 2000
- name: Verify pnpm-lock.yaml
- name: Verify yarn.lock
run: |
git diff --quiet --exit-code || {
echo '::error::`pnpm-lock.yaml` does not match what pnpm would generate given the base `pnpm-lock.yaml` and the head `package.json`.'
echo '::error::`yarn.lock` does not match what Yarn would generate given the base `yarn.lock` and the head `package.json`.'
echo '::error:: - If this is intentional, you can ignore this check.'
echo '::error:: - If this is unintentional, apply the following diff:'
git --no-pager diff
-6
View File
@@ -86,9 +86,6 @@ stats.json
# VSCode
.vscode
# Zed
.zed
# gitignore and github actions
!.gitignore
!.github
@@ -130,6 +127,3 @@ bskyweb/static/media/*.webp
bskyweb/static/media/*.jpg
bskyweb/static/media/*.png
bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
+4 -1
View File
@@ -1 +1,4 @@
pnpm exec lint-staged --concurrent false
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged --concurrent false
-236
View File
@@ -1,236 +0,0 @@
/**
* Codemod to update Lingui's useLingui() hook to use v5.
*
* Before:
* import {msg} from '@lingui/core/macro'
* import {useLingui} from '@lingui/react'
* const {_} = useLingui()
* _(msg`Lorem ipsum`)
*
* After:
* import {useLingui} from '@lingui/react/macro'
* const {t: l} = useLingui()
* l`Lorem ipsum`
*
* Usage: jscodeshift -t .jscodeshift/file/lingui-v5.js <file-path>
* Example: jscodeshift -t .jscodeshift/file/lingui-v5.js modules/bottom-sheet/src/lib/Portal.tsx
*/
/* eslint-disable */
export const parser = 'tsx'
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Remove msg imports, including aliased imports
root
.find(j.ImportDeclaration)
.filter(path => path.node.source.value === '@lingui/core/macro')
.forEach(path => {
const newSpecifiers = path.node.specifiers.filter(specifier => {
if (j.ImportSpecifier.check(specifier)) {
const importedName = specifier.imported
? specifier.imported.name
: specifier.local.name
const localName = specifier.local
? specifier.local.name
: importedName
return !(
importedName === 'msg' &&
(localName === 'msg' || localName === 'msgLingui')
)
}
return true
})
if (newSpecifiers.length > 0) {
path.node.specifiers = newSpecifiers
} else {
j(path).remove()
}
})
// Update React imports
root
.find(j.ImportDeclaration)
.filter(path => path.node.source.value === '@lingui/react')
.forEach(path => {
path.node.source = j.stringLiteral('@lingui/react/macro')
})
// Replace {_} with {t: l}
root
.find(j.VariableDeclarator, {
init: {
type: 'CallExpression',
callee: {name: 'useLingui'},
},
})
.replaceWith(nodePath => {
const {node} = nodePath
for (let i = 0; i < node.id.properties.length; i++) {
if (node.id.properties[i].key.name === '_') {
node.id.properties[i].key = j.identifier('t')
node.id.properties[i].shorthand = false
node.id.properties[i].value = j.identifier('l')
}
}
return node
})
// Replace {_: _l} with {t: l}
root
.find(j.VariableDeclarator, {
init: {callee: {name: 'useLingui'}},
id: {
properties: properties =>
properties.some(p => p.key.name === '_' && p.value.name === '_l'),
},
})
.forEach(path => {
const prop = path.node.id.properties.find(
p => p.key.name === '_' && p.value.name === '_l',
)
if (prop) {
prop.key.name = 't'
prop.value.name = 'l'
}
})
// Replace _(msg``) with l``
root
.find(j.CallExpression, {
callee: {name: '_'},
arguments: [
{
type: 'TaggedTemplateExpression',
tag: {name: 'msg'},
},
],
})
.replaceWith(path => {
const quasis = path.node.arguments[0].quasi
return j.taggedTemplateExpression(j.identifier('l'), quasis)
})
// Replace _l(msgLingui``) with l``
root
.find(j.CallExpression, {
callee: {name: '_l'},
arguments: [
{
type: 'TaggedTemplateExpression',
tag: {name: 'msgLingui'},
},
],
})
.replaceWith(path => {
const quasis = path.node.arguments[0].quasi
return j.taggedTemplateExpression(j.identifier('l'), quasis)
})
// Replace _(msg()) with l()
root
.find(j.CallExpression, {
callee: {name: '_'},
arguments: [
{
type: 'CallExpression',
callee: {name: 'msg'},
},
],
})
.replaceWith(path => {
return j.callExpression(j.identifier('l'), [
path.node.arguments[0].arguments[0],
])
})
// Replace _l(msgLingui()) with l()
root
.find(j.CallExpression, {
callee: {name: '_l'},
arguments: [
{
type: 'CallExpression',
callee: {name: 'msgLingui'},
},
],
})
.replaceWith(path => {
return j.callExpression(j.identifier('l'), [
path.node.arguments[0].arguments[0],
])
})
// Replace _(plural()) with plural()
root
.find(j.CallExpression, {
callee: {
type: 'Identifier',
name: '_',
},
})
.forEach(path => {
const callExpression = path.node
if (
callExpression.arguments.length === 1 &&
callExpression.arguments[0].type === 'CallExpression'
) {
const innerCall = callExpression.arguments[0]
if (
innerCall.callee.type === 'Identifier' &&
innerCall.callee.name === 'plural'
) {
j(path).replaceWith(innerCall)
}
}
})
// Update hook dependency arrays
root
.find(j.CallExpression, {
callee: callee => {
if (
callee.type === 'Identifier' ||
callee.type === 'MemberExpression'
) {
return true
}
if (
['useCallback', 'useEffect', 'useMemo'].includes(callee.name) &&
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'React' &&
callee.property.type === 'Identifier' &&
callee.property.name === 'useCallback'
) {
return true
}
return false
},
})
.forEach(path => {
const dependencyArray = path.node.arguments[1]
if (dependencyArray && dependencyArray.type === 'ArrayExpression') {
dependencyArray.elements = dependencyArray.elements.map(element => {
if (
element.type === 'Identifier' &&
(element.name === '_' || element.name === '_l')
) {
return j.identifier('l')
}
return element
})
}
})
return root.toSource()
}
@@ -9,8 +9,8 @@
* import { useEffect } from 'react'
* useEffect(() => {}, [])
*
* Usage: jscodeshift -t .jscodeshift/repo/react-import.js <file-path>
* Example: jscodeshift -t .jscodeshift/repo/react-import.js src/App.native.tsx
* Usage: jscodeshift -t .jscodeshift/react-import.js <file-path>
* Example: jscodeshift -t .jscodeshift/react-import.js src/App.native.tsx
*/
/* eslint-disable */
@@ -9,8 +9,8 @@
* import * as Toast from '#/components/Toast'
* Toast.show(message, {type: 'error'})
*
* Usage: jscodeshift -t .jscodeshift/repo/toast-v2.js <file-path>
* Example: jscodeshift -t .jscodeshift/repo/toast-v2.js src/App.native.tsx
* Usage: jscodeshift -t .jscodeshift/toast-v2.js <file-path>
* Example: jscodeshift -t .jscodeshift/toast-v2.js src/App.native.tsx
*/
/* eslint-disable */
+1 -1
View File
@@ -1 +1 @@
24.15.0
20
-4
View File
@@ -5,14 +5,10 @@
!**/*.jsx
!**/*.ts
!**/*.tsx
!**/*.mjs
!**/*.cjs
!*/
!**/*.css
!**/pnpm-workspace.yaml
# More specific ignores go below.
.expo
android
+1 -5
View File
@@ -1,6 +1,4 @@
import {type Config} from 'prettier'
const config: Config = {
module.exports = {
semi: false,
arrowParens: 'avoid',
bracketSameLine: true,
@@ -8,5 +6,3 @@ const config: Config = {
singleQuote: true,
trailingComma: 'all',
}
export default config
+97 -185
View File
@@ -1,4 +1,4 @@
# CLAUDE.md  Bluesky Social App Development Guide
# CLAUDE.md - Bluesky Social App Development Guide
This document provides guidance for working effectively in the Bluesky Social app codebase.
@@ -7,40 +7,36 @@ This document provides guidance for working effectively in the Bluesky Social ap
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
**Tech Stack:**
- React 19.1
- React Native 0.81 with Expo 54
- TypeScript 6
- React Navigation 7 for routing
- TypeScript
- React Navigation for routing
- TanStack Query (React Query) for data fetching
- Lingui 5 for internationalization
- Lingui for internationalization
- Custom design system called ALF (Application Layout Framework)
Prefer using the latest features available for each of these libraries (exact versions are found in `package.json`). For example, prefer `@lingui/react/macro` over `@lingui/react`. Suggest refactoring legacy or deprecated uses.
## Essential Commands
```bash
# Development
pnpm start # Start Expo dev server
pnpm web # Start web version
pnpm android # Run on Android
pnpm ios # Run on iOS
yarn start # Start Expo dev server
yarn web # Start web version
yarn android # Run on Android
yarn ios # Run on iOS
# Testing & Quality
# IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly
pnpm test # Run Jest tests
pnpm lint # Run ESLint
pnpm typecheck # Run TypeScript type checking
# IMPORTANT: Always use these yarn scripts, never call the underlying tools directly
yarn test # Run Jest tests
yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking
# Internationalization
# DO NOT run these commands - extraction and compilation are handled by CI
pnpm intl:extract # Extract translation strings (nightly CI job)
pnpm intl:compile # Compile translations for runtime (nightly CI job)
yarn intl:extract # Extract translation strings (nightly CI job)
yarn intl:compile # Compile translations for runtime (nightly CI job)
# Build
pnpm build-web # Build web version
pnpm prebuild # Generate native projects
yarn build-web # Build web version
yarn prebuild # Generate native projects
```
## Project Structure
@@ -166,10 +162,6 @@ its own looks more like a single component file.
### Documentation and Tests Within Features
Comment code when necessary to explain the “why” behind something; avoid
comments that simply describe the code. Avoid Unicode characters in comments,
e.g., use `-` not `—`.
For larger features or components, it's helpful to include a README.md file
within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends
@@ -190,14 +182,6 @@ ALF is the custom design system. It uses Tailwind-inspired naming with underscor
### Basic Usage
Generally, order atoms by:
- Flexbox configuration, e.g., `a.flex_row`
- Spacing, e.g., `a.px_md`
- Text styles, e.g., `a.font_bold`
- Themes, e.g., `t.atoms.text`,
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
```tsx
import {atoms as a, useTheme} from '#/alf'
@@ -206,7 +190,9 @@ function MyComponent() {
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
Hello
</Text>
</View>
)
}
@@ -214,23 +200,20 @@ function MyComponent() {
### Key Concepts
**Static Atoms**  Theme-independent styles imported from `atoms`:
**Static Atoms** - Theme-independent styles imported from `atoms`:
```tsx
import {atoms as a} from '#/alf'
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
```
**Theme Atoms**  Theme-dependent colors from `useTheme()`:
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
```tsx
const t = useTheme()
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
// t.palette.primary_500, t.palette.negative_400, etc.
```
**Platform Utilities**  For platform-specific styles:
**Platform Utilities** - For platform-specific styles:
```tsx
import {web, native, ios, android, platform} from '#/alf'
@@ -242,8 +225,7 @@ const styles = [
]
```
**Breakpoints**  Responsive design:
**Breakpoints** - Responsive design:
```tsx
import {useBreakpoints} from '#/alf'
@@ -263,38 +245,6 @@ if (gtMobile) {
## Component Patterns
- Prefer fragment shorthand over `Fragment` unless a `key` is needed.
- Prefer functions over arrow functions for component declarations.
- Prefer prop destructuring via parameters over a const within the component.
- Prefer inline types over `Props` types or interfaces.
- Set reasonable defaults for optional props.
```tsx
import {Fragment} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {Text} from '#/components/Typography'
function MyComponent({foo = []}: {foo?: string[]}) {
const {t: l} = useLingui()
return (
<>
<View><Text><Trans>Example</Trans><Text></View>
<View>
{foo.map((foo, index) => (
<Fragment key={foo}>
<Text>{index}</Text>
<Text>{foo}</Text>
</Fragment>
))}
</View>
</>
);
}
```
### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
@@ -313,26 +263,20 @@ function MyFeature() {
<Dialog.Outer control={control}>
{/* Typically the inner part is in its own component */}
<DialogInner />
</Dialog.Outer>
</>
)
}
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
function DialogInner() {
return (
<>
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={l`My Dialog`}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
@@ -401,7 +345,6 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
```
**Button Props:**
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
@@ -414,16 +357,12 @@ import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
// will almost certainly contain emoji, so only omit it when the text is static and
// does not contain an emoji
// For text with emoji, add the emoji prop
<Text emoji>Hello! 👋</Text>
```
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
### TextField
```tsx
@@ -445,70 +384,43 @@ import * as TextField from '#/components/forms/TextField'
## Internationalization (i18n)
All user-facing strings must be wrapped for translation using Lingui. Include `comment` and/or `context` props when necessary to avoid ambiguity, e.g., “Post” as a noun vs a verb.
Prefer using `t` via `import {useLingui} '@lingui/react/macro'` vs `_` via `import {useLingui} from '@lingui/react'`. Alias `t` to `l` to avoid collisions with `const t = useTheme()`. Refactor existing uses of ``_(msg`foo`)`` to use `` l`foo` ``.
Prefer Unicode punctuation over keyboard punctuation, e.g., `“quote”` over `"quote"`. Prefer en dashes preceded by a non-breaking space over em dashes, e.g., `one  two` over `one—two`.
All user-facing strings must be wrapped for translation using Lingui.
```tsx
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {msg, plural} from '@lingui/core/macro'
import {Trans} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
function MyComponent() {
const {t: l} = useLingui()
const {_} = useLingui()
// Simple strings - use the l macro
const title = l`Settings`
const errorMessage = l({
message: 'Something went wrong',
comment: 'Generic error message for unknown/unhandled errors.',
context: 'Toast',
})
// Simple strings - use msg() with _() function
const title = _(msg`Settings`)
const errorMessage = _(msg`Something went wrong`)
// Strings with variables
const greeting = l`Hello, ${name}!`
const greeting = _(msg`Hello, ${name}!`)
// Pluralization
const countLabel = plural(count, {
const countLabel = _(plural(count, {
one: '# item',
other: '# items',
})
}))
// JSX content - use Trans component
return (
<Text>
<Trans>
Welcome to <Text style={a.font_bold}>Bluesky</Text>, {name}!
</Trans>
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
</Text>
)
}
```
Prefer `i18n.date` for date and time formatting. This ensures formatting is re-applied when the language changes at runtime. Refactor existing uses of `Intl.DateTimeFormat` to use `i18n.date`.
```tsx
import {useLingui} from '@lingui/react/macro'
function MyComponent() {
const {i18n} = useLingui()
const createdAt = new Date()
return i18n.date(createdAt, {
dateStyle: 'medium',
timeStyle: 'medium',
})
}
```
**Commands:**
```bash
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
pnpm intl:extract # Extract new strings to locale files
pnpm intl:compile # Compile translations for runtime
yarn intl:extract # Extract new strings to locale files
yarn intl:compile # Compile translations for runtime
```
## State Management
@@ -561,7 +473,7 @@ export function useProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async data => {
mutationFn: async (data) => {
// Update logic
},
onSuccess: (_, variables) => {
@@ -569,7 +481,7 @@ export function useProfileMutation() {
queryKey: createProfileQueryKey({did: variables.did}),
})
},
onError: error => {
onError: (error) => {
if (isNetworkError(error)) {
// don't log, but inform user
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
@@ -578,7 +490,7 @@ export function useProfileMutation() {
// Log unexpected errors to Sentry
logger.error('Error updating profile', {safeMessage: error})
}
},
}
})
}
@@ -593,25 +505,21 @@ export function useProfileCacheMutation() {
const queryClient = useQueryClient()
return (data: Partial<Profile>) => {
queryClient.setQueryData(
createProfileQueryKey({did: data.did}),
oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
},
)
queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
})
}
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
```tsx
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
```
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
@@ -657,7 +565,12 @@ function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
return (
<Toggle
value={autoplayDisabled}
onValueChange={setAutoplayDisabled}
/>
)
}
```
@@ -691,9 +604,13 @@ import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params
const {name} = route.params // Type-safe params
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
return (
<Layout.Screen>
{/* Screen content */}
</Layout.Screen>
)
}
// Programmatic navigation
@@ -720,9 +637,8 @@ Component.android.tsx # Android-only
```
Example from Dialog:
- `src/components/Dialog/index.tsx Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx Web (uses modal with Radix primitives)
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
@@ -737,7 +653,6 @@ const storage = IS_NATIVE
```
Platform detection (for runtime logic, not imports):
```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
@@ -772,13 +687,13 @@ Common pitfalls to avoid in this codebase:
// WRONG - causes bugs with state updates, navigation, opening other dialogs
const onConfirm = () => {
control.close()
navigation.navigate('Home') // May race with dialog animation
navigation.navigate('Home') // May race with dialog animation
}
// WRONG - same problem
const onConfirm = () => {
control.close()
otherDialogControl.open() // Will likely fail or cause visual glitches
otherDialogControl.open() // Will likely fail or cause visual glitches
}
// CORRECT - action runs after dialog fully closes
@@ -805,13 +720,12 @@ const onConfirm = () => {
```
This applies to:
- Navigation (`navigation.navigate()`, `navigation.push()`)
- Opening other dialogs or menus
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
- Callbacks passed from parent components
The Menu component on iOS specifically uses this pattern  see `src/components/Menu/index.tsx:151`.
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
### Controlled vs Uncontrolled Inputs
@@ -834,11 +748,10 @@ Prefer `defaultValue` over `value` for TextInput on the old architecture:
### Platform-Specific Behavior
Some components behave differently across platforms:
- `Dialog.Handle Only renders on native (drag handle for bottom sheet)
- `Dialog.Close Only renders on web (X button)
- `Menu.Divider Only renders on web
- `Menu.ContainerItem Only works on native
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
- `Dialog.Close` - Only renders on web (X button)
- `Menu.Divider` - Only renders on web
- `Menu.ContainerItem` - Only works on native
Always test on multiple platforms when using these components.
@@ -859,7 +772,6 @@ const handlePress = () => {
```
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
- The value is immediately used in an effect's dependency array
- You're passing a callback to a non-React library that needs referential stability
@@ -867,7 +779,7 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
2. **Translations**: Wrap ALL user-facing strings with ` `l` `` or `<Trans>`
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
@@ -881,14 +793,14 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
## Key Files Reference
| Purpose | Location |
| ----------------- | -------------------------------------------- |
| Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` |
| Purpose | Location |
|---------|----------|
| Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` |
+41 -49
View File
@@ -1,21 +1,23 @@
#
# Stage 1: build the web bundle with pnpm.
#
# Uses the official pnpm image. Node is auto-downloaded by pnpm using the
# `devEngines.runtime` field in package.json (onFail: "download").
#
FROM ghcr.io/pnpm/pnpm:11 AS web-build
FROM golang:1.26-bookworm AS build-env
WORKDIR /app
WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
#
# pnpm config
# Node
#
ENV CI=1
# use the pnpm version specified in package.json
ENV pnpm_config_pm_on_fail=download
ENV NODE_VERSION=20
ENV NVM_DIR=/usr/share/nvm
#
# Go
#
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
# The latest git hash of the preview branch on render.com
# https://render.com/docs/docker-secrets#environment-variables-in-docker-builds
@@ -40,48 +42,41 @@ ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN:-unknown}
ARG EXPO_PUBLIC_SENTRY_DSN
ENV EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN
#
# Copy everything into the container
#
COPY . .
RUN echo "Using bundle identifier: $EXPO_PUBLIC_BUNDLE_IDENTIFIER" && \
#
# Generate the JavaScript webpack.
#
RUN mkdir --parents $NVM_DIR && \
wget \
--output-document=/tmp/nvm-install.sh \
https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh && \
bash /tmp/nvm-install.sh
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
echo "Using bundle identifier: $EXPO_PUBLIC_BUNDLE_IDENTIFIER" && \
echo "EXPO_PUBLIC_ENV=$EXPO_PUBLIC_ENV" >> .env && \
echo "EXPO_PUBLIC_RELEASE_VERSION=$EXPO_PUBLIC_RELEASE_VERSION" >> .env && \
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER" >> .env && \
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env && \
echo "EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN" >> .env
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm intl:build 2>&1 | tee i18n.log && \
if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compile errors!\n\n"; fi
RUN SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN \
SENTRY_RELEASE=$EXPO_PUBLIC_RELEASE_VERSION \
SENTRY_DIST=$EXPO_PUBLIC_BUNDLE_IDENTIFIER \
pnpm build-web
#
# Stage 2: build the bskyweb Go binary, embedding the assets from stage 1.
#
# post-web-build.js (run by `pnpm build-web`) writes the bundled JS/CSS/media
# into bskyweb/static/* and regenerates bskyweb/templates/scripts.html, so
# copying the bskyweb/ tree from stage 1 is enough for go:embed to find
# everything.
#
FROM golang:1.26-bookworm AS go-build
WORKDIR /usr/src/social-app
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
COPY --from=web-build /app/bskyweb ./bskyweb
echo "EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN" >> .env && \
npm install --global yarn && \
yarn && \
yarn intl:build 2>&1 | tee i18n.log && \
if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compile errors!\n\n"; fi && \
SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN SENTRY_RELEASE=$EXPO_PUBLIC_RELEASE_VERSION SENTRY_DIST=$EXPO_PUBLIC_BUNDLE_IDENTIFIER yarn build-web
# DEBUG
RUN find ./bskyweb/static
RUN find ./bskyweb/static && find ./web-build/static
#
# Generate the bskyweb Go binary.
#
RUN cd bskyweb/ && \
go mod download && \
go mod verify
@@ -94,9 +89,6 @@ RUN cd bskyweb/ && \
-o /bskyweb \
./cmd/bskyweb
#
# Stage 3: runtime image.
#
FROM debian:bookworm-slim
ENV GODEBUG=netdns=go
@@ -110,7 +102,7 @@ RUN apt-get update && apt-get install --yes \
ENTRYPOINT ["dumb-init", "--"]
WORKDIR /bskyweb
COPY --from=go-build /bskyweb /usr/bin/bskyweb
COPY --from=build-env /bskyweb /usr/bin/bskyweb
CMD ["/usr/bin/bskyweb"]
+4 -2
View File
@@ -1,4 +1,4 @@
FROM node:24.15.0-alpine3.22 AS build
FROM node:20.11-alpine3.18 as build
# Move files into the image and install
WORKDIR /app
@@ -14,7 +14,7 @@ RUN yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:24.15.0-alpine3.22
FROM node:20.11-alpine3.18
RUN apk add --update dumb-init
@@ -29,6 +29,8 @@ VOLUME /app/data
EXPOSE 3000
ENV LINK_PORT=3000
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
+8 -11
View File
@@ -1,25 +1,20 @@
FROM node:24.15.0-alpine3.22 AS build
# Tells pnpm to run non-interactively (needed for install/script steps)
ENV CI=true
FROM node:20.11-alpine3.18 as build
# Move files into the image and install
WORKDIR /app
COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/pnpm-lock.yaml ./
COPY ./bskyogcard/pnpm-workspace.yaml ./
RUN npm install --global pnpm@11.1.3
RUN pnpm install --frozen-lockfile
COPY ./bskyogcard/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY ./bskyogcard ./
# build then prune dev deps
RUN pnpm install-fonts && pnpm build
RUN pnpm install --prod --ignore-scripts --prefer-offline
RUN yarn install-fonts && yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:24.15.0-alpine3.22
FROM node:20.11-alpine3.18
RUN apk add --update dumb-init
@@ -35,6 +30,8 @@ EXPOSE 3000 3001
ENV CARD_PORT=3000
ENV CARD_METRICS_PORT=3001
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
+7 -11
View File
@@ -5,20 +5,16 @@ WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
# Node
ENV NODE_VERSION=24.15.0
ENV NODE_VERSION=20
ENV NVM_DIR=/usr/share/nvm
# Go
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ARG TARGETARCH
ENV GOARCH=${TARGETARCH:-amd64}
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
# CI environment for pnpm
ENV CI=true
COPY . .
#
@@ -33,11 +29,11 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global pnpm@11.5.0 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
pnpm build-embed
npm install --global yarn && \
yarn && \
cd bskyembed && yarn install --frozen-lockfile && cd .. && \
yarn intl:build && \
yarn build-embed
# DEBUG
RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bskyembed/dist
+11 -11
View File
@@ -10,33 +10,33 @@ help: ## Print info about all commands
.PHONY: build-web
build-web: ## Compile web bundle, copy to bskyweb directory
pnpm intl:build
pnpm build-web
yarn intl:build
yarn build-web
.PHONY: build-web-embed
build-web-embed: ## Compile web embed bundle, copy to bskyweb/embedr* directories
pnpm intl:build
pnpm build-embed
yarn intl:build
yarn build-embed
.PHONY: test
test: ## Run all tests
NODE_ENV=test pnpm test
NODE_ENV=test yarn test
.PHONY: lint
lint: ## Run style checks and verify syntax
pnpm run lint
yarn run lint
#.PHONY: fmt
#fmt: ## Run syntax re-formatting
# pnpm prettier
# yarn prettier
.PHONY: deps
deps: ## Installs dependent libs using 'pnpm install'
pnpm install --frozen-lockfile
deps: ## Installs dependent libs using 'yarn install'
yarn install --frozen-lockfile
cd bskyembed && yarn install --frozen-lockfile
.PHONY: nvm-setup
nvm-setup: ## Use NVM to install and activate node+pnpm
nvm-setup: ## Use NVM to install and activate node+yarn
nvm install 20
nvm use 20
npm install --global pnpm
npm install --global yarn
+7 -56
View File
@@ -8,7 +8,7 @@ import {
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
import {klipyUrlToBskyGifUrl} from '#/features/gifPicker/utils'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
@@ -450,13 +450,6 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://sufjanstevens.bandcamp.com',
'https://bandcamp.com/',
'https://bandcamp.com',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/other/path.gif?hh=200&ww=300',
'https://static.klipy.com',
]
const outputs = [
@@ -852,35 +845,6 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
undefined,
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
// With video slug params — on native (test env), keeps gif filename,
// strips mp4/webm params. On web, would swap to video filename.
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
undefined,
undefined,
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
@@ -1071,30 +1035,17 @@ describe('createStarterPackGooglePlayUri', () => {
})
})
describe('klipyUrlToBskyGifUrl', () => {
describe('tenorUrlToBskyGifUrl', () => {
const inputs = [
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
'https://media.tenor.com/someID_AAAAC/someName.gif',
'https://media.tenor.com/someID/someName.gif',
]
it.each(inputs)(
'returns url with k.gifs.bsky.app as hostname for input url',
'returns url with t.gifs.bsky.app as hostname for input url',
input => {
const out = klipyUrlToBskyGifUrl(input)
expect(out.startsWith('https://k.gifs.bsky.app/')).toEqual(true)
const out = tenorUrlToBskyGifUrl(input)
expect(out.startsWith('https://t.gifs.bsky.app/')).toEqual(true)
},
)
it('preserves the path and query params when rewriting', () => {
const out = klipyUrlToBskyGifUrl(
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
)
expect(out).toEqual(
'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
)
})
it('returns empty string for invalid URLs', () => {
expect(klipyUrlToBskyGifUrl('not-a-url')).toEqual('')
})
})
-3
View File
@@ -66,7 +66,6 @@ module.exports = function (_config) {
infoPlist: {
CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'],
NSUserActivityTypes: ['INSendMessageIntent'],
NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.',
NSMicrophoneUsageDescription:
@@ -124,7 +123,6 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky',
'com.apple.developer.usernotifications.communication': true,
// 'com.apple.developer.device-information.user-assigned-device-name': true,
},
privacyManifests: {
@@ -262,7 +260,6 @@ module.exports = function (_config) {
deploymentTarget: '15.1',
buildReactNativeFromSource: true,
ccacheEnabled: IS_DEV,
cxxLanguageStandard: 'c++23',
extraPods: [
{
name: 'MCEmojiPicker',
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17 3a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4h-2a1 1 0 1 1 0-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h2Zm-6.707 4.793a1 1 0 0 1 1.414 0l3.5 3.5a1 1 0 0 1 0 1.414l-3.5 3.5a1 1 0 1 1-1.414-1.414L12.086 13H4a1 1 0 1 1 0-2h8.086l-1.793-1.793a1 1 0 0 1 0-1.414Z"/></svg>

Before

Width:  |  Height:  |  Size: 360 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#111822" d="M55 32C55 19.298 44.703 9 32 9S9 19.298 9 32c0 3.463.765 6.745 2.134 9.688.358.769.479 1.658.267 2.525l-2.076 8.51a1.667 1.667 0 0 0 1.987 2.02l8.876-2.005a3.84 3.84 0 0 1 2.412.26A22.9 22.9 0 0 0 32 55c12.703 0 23-10.297 23-23Zm2 0c0 13.807-11.193 25-25 25-3.638 0-7.098-.778-10.219-2.177a1.84 1.84 0 0 0-1.152-.133l-8.877 2.004c-2.655.6-5.015-1.802-4.37-4.446l2.077-8.51a1.84 1.84 0 0 0-.139-1.207A24.9 24.9 0 0 1 7 32C7 18.193 18.193 7 32 7s25 11.193 25 25Z"/><path fill="#111822" d="M17.667 32a2.333 2.333 0 1 0 4.667 0 2.333 2.333 0 0 0-4.667 0Zm24 0a2.334 2.334 0 1 0 4.667 0 2.334 2.334 0 0 0-4.667 0Z"/><path fill="#000" d="M35.137 37.215a1 1 0 0 1 1.414 1.414 6.143 6.143 0 0 1-8.687 0 1 1 0 0 1 1.415-1.414 4.14 4.14 0 0 0 5.858 0Z"/></svg>

Before

Width:  |  Height:  |  Size: 847 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M14.3 23v-1.1a1 1 0 0 1 2 0V23a1 1 0 1 1-2 0Zm5.243-3.457a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM4.788 9.298a1 1 0 0 1 1.424 1.404l-.742.752-.004.005a5.003 5.003 0 1 0 7.075 7.075l.005-.004.752-.742a1 1 0 0 1 1.404 1.424l-.747.736a7.003 7.003 0 1 1-9.904-9.904l.737-.746ZM23 14.3a1 1 0 0 1 0 2h-1.1a1 1 0 1 1 0-2H23ZM10.044 4.05a7.005 7.005 0 0 1 9.905 9.906h0l-.737.746a1 1 0 0 1-1.424-1.404l.742-.752.004-.005a5.003 5.003 0 1 0-7.075-7.075l-.005.004-.752.742a1 1 0 0 1-1.404-1.424l.746-.737ZM2.1 7.7a1 1 0 1 1 0 2H1a1 1 0 0 1 0-2h1.1Zm-.157-5.757a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM7.7 2.1V1a1 1 0 1 1 2 0v1.1a1 1 0 0 1-2 0Z"/></svg>

Before

Width:  |  Height:  |  Size: 807 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17.058 1.001c.466-.012 1.294.05 1.828.782.099.136.172.274.224.405.385-.036.92-.08 1.416-.039.48.04 1.448.197 2.012 1.09.497.787.372 1.592.13 2.14-.14.315-.345.602-.505.807.172.15.366.354.518.613.366.622.383 1.312.22 1.962-.285 1.143-1.24 1.642-1.82 1.865-.597.23-1.296.354-1.818.448l-.06.01-.121.022c.128.445.101.918-.048 1.367-.41 1.242-1.349 1.831-2.24 2.08-.764.214-1.618.213-2.225.212h-.218c.152.615-.022 1.221-.192 1.631-.63 1.514-2.053 2.08-3.203 2.319s-2.484.247-3.591.253h-.076c-.607.004-1.145.007-1.616.034-.417.806-.763 1.702-1.163 2.75l-.002.002q-.158.414-.327.85c-.446 1.14-1.709 1.693-2.82 1.235-1.11-.459-1.65-1.755-1.204-2.897.571-1.462 1.164-2.48 1.954-4.046.212-.42.39-.76.547-1.075 0-.951.07-1.624.207-2.476.23-1.432.423-1.984.707-3.29.26-.903.644-2.115 1.247-2.783.346-.384.88-.945 1.685-.983.489-.023.794.38 1.052.553.749-1.04 1.384-2.427 2.202-3.503.657-.863 1.363-1.618 2.503-1.438.588.093 1.423.547 1.424 1.438.217-.249.883-1.204 1.162-1.438.532-.445 1.27-.874 2.21-.9m-.42 1.154c-1.003.028-1.71.904-2.303 1.64-.43.532-.8.991-1.18 1.001-.373.01-.345-.247-.31-.572.05-.464.115-1.066-.945-1.234-.879-.14-1.805 1.531-2.61 2.981-.551.993-1.046 1.883-1.428 2.019-.584.208-.854-.037-1.085-.245-.28-.252-.5-.451-1.147.265-.681.755-1.146 2.82-1.596 5.068-.057.287.342.472.525.247 1.036-1.28 2.72-3.039 4.73-4.34 1.848-1.196 3.63-2.385 5.647-2.983.297-.088.38.253.096.377-2.273.994-4.226 2.304-6.272 4.053-2.299 1.923-4.474 4.483-5.577 6.57a27 27 0 0 0-1.829 3.786.724.724 0 0 0 .388.933.693.693 0 0 0 .91-.398q.162-.418.316-.825c.473-1.234.915-2.391 1.539-3.443a.25.25 0 0 1 .187-.123c.678-.082 1.485-.087 2.32-.092 2.277-.013 4.764-.028 5.431-1.632.348-.838-.071-.959-.45-1.067-.31-.089-.593-.17-.406-.629.323-.792 1.377-.793 2.494-.793 1.301 0 2.687 0 3.103-1.254.177-.537-.14-.748-.42-.934-.235-.157-.444-.296-.317-.596.201-.477 1.078-.636 2.007-.803 1.11-.2 2.294-.412 2.49-1.198.186-.744-.16-.983-.52-1.232-.218-.151-.44-.307-.55-.582-.133-.337.158-.697.461-1.071.358-.442.731-.903.438-1.367-.334-.527-1.327-.434-2.15-.358-.512.048-.959.089-1.136-.03-.18-.12-.173-.325-.166-.528.011-.317.022-.63-.685-.611"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.54729 11.8415C6.66801 12.836 7.11067 14.8647 8.11671 16.5751C8.23744 16.8138 8.55937 16.774 8.63986 16.5751L14.4346 6.07367C14.5553 5.87478 14.5151 5.59634 14.3139 5.43722C13.4688 4.72121 12.3823 4.28365 11.0946 4.32343C6.46681 4.44276 6.42657 10.2106 6.54729 11.8415ZM12.9054 19.638C17.4125 19.5187 17.5734 13.7907 17.4125 12.1995C17.332 11.1653 16.8894 9.09681 15.8431 7.3068C15.7626 7.14769 15.4809 7.14769 15.4004 7.3068L9.56541 17.8878C9.48492 18.1265 9.52517 18.3652 9.68613 18.5243C10.5312 19.2403 11.6177 19.6778 12.9054 19.638ZM17.8149 2.93119L17.5734 3.36875C17.4125 3.6472 17.493 3.96543 17.7345 4.08476C20.9537 6.11345 22 9.61393 22 11.9211C22 14.7851 19.5856 21.4678 12.2213 21.4678C10.7727 21.4678 9.52517 21.2689 8.3984 20.9109C8.1972 20.8314 7.9155 20.9109 7.79478 21.1098L7.47284 21.7463C7.35212 21.9849 7.07043 22.0645 6.82898 21.9452L6.38632 21.7065C6.14488 21.5872 6.0644 21.3088 6.18512 21.0701L6.46681 20.5529C6.58753 20.3143 6.50705 20.0358 6.30584 19.9165C3.08652 17.848 2 14.3078 2 12.0802C2 9.21615 4.21328 2.53341 11.6177 2.53341C13.1469 2.53341 14.4346 2.7323 15.5614 3.0903C15.8028 3.16986 16.0846 3.05053 16.2053 2.85163L16.5674 2.21518C16.6881 2.01629 16.9699 1.93673 17.1711 2.05607L17.654 2.29474C17.8552 2.41407 17.9357 2.69252 17.8149 2.93119Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M4.686 8.752c1.117 0 1.88.517 2.246 1.52q.247.678.249 1.643l-.001.097q-.014.94-.267 1.6l-.074.176c-.394.858-1.123 1.303-2.153 1.303a2.32 2.32 0 0 1-1.408-.455l-.035-.028v1.966a1 1 0 0 1-.008.074c-.029.178-.146.267-.359.267H.854q-.273 0-.335-.216l-.01-.035a1 1 0 0 1-.009-.09v-7.34a.7.7 0 0 1 .02-.149.27.27 0 0 1 .155-.18l.027-.01.005-.002a.5.5 0 0 1 .126-.02h2.066q.04 0 .076.007h.007l.048.013a.27.27 0 0 1 .204.231l.005.028a1 1 0 0 1 .004.07 2 2 0 0 1 .185-.131l.039-.024a2 2 0 0 1 .289-.144q.413-.17.93-.171m5.367.041c.46 0 .958.048 1.655.289l.015.005.021.008.06.021.02.007.018.008.017.007.033.014.024.012.015.008.02.01.003.004.01.005.006.004.01.007.003.003.01.008.005.003.009.008.003.003.009.007.011.012.008.009.004.004.006.008.005.005.005.007.013.023.005.009.004.007.002.003a.4.4 0 0 1 .036.134l.004.05.083 1.389a1 1 0 0 1 0 .083v.01l-.004.03-.007.043-.002.008q0 .006-.004.012v.005l-.005.014-.005.014-.002.003-.005.011-.002.004-.006.01-.002.004-.005.008q0 .003-.003.005l-.003.003-.006.009-.003.004-.018.016-.023.018-.005.003-.01.005h-.002l-.01.006q-.004 0-.008.003l-.007.002-.007.002-.009.003-.007.001-.01.003-.005.001-.013.002H12l-.012.002h-.007l-.013.001h-.048l-.013-.003-.057-.008-.124-.02a9 9 0 0 0-1.272-.112c-.804 0-1.146.265-1.146.857 0 .495.212.857 1.134.857a10 10 0 0 0 1.396-.12c.271-.049.366.06.354.326l-.083 1.388a1 1 0 0 1-.01.093l-.001.008-.003.013-.002.006-.004.012-.018.047-.007.016-.002.002-.006.011-.003.006c-.068.107-.192.153-.358.21a4.5 4.5 0 0 1-1.655.3c-1.24 0-2.135-.377-2.706-.996.306-.61.445-1.365.445-2.219 0-.828-.13-1.562-.418-2.16.574-.6 1.464-.963 2.69-.963m11.419-1.227q.354 0 .354.363v1.073c.386.048.796.116 1.265.21.2.041.327.242.285.446l-.307 1.477a.374.374 0 0 1-.438.293 17 17 0 0 0-.805-.142v.603c0 .724.425.82.71.821.117 0 .33-.024.52-.048.271-.036.39.072.378.338l-.082 1.46c-.012.29-.166.46-.415.52a5.5 5.5 0 0 1-1.24.145c-1.869 0-2.614-.978-2.614-2.982v-.985l-.165.008a.4.4 0 0 1-.262-.085l-.002-.002a.4.4 0 0 1-.04-.039l-.004-.003a.4.4 0 0 1-.05-.071l-.003-.005a.4.4 0 0 1-.023-.052l-.002-.005-.008-.025v-.006a.4.4 0 0 1-.01-.057l-.001-.011v-.006l-.001-.019v-.784c0-.165.07-.322.193-.43.191-.168.493-.435.759-.673.154-.139.296-.268.397-.363l.003-.003.744-.683c.201-.181.438-.278.71-.278zM14.052 7c.249 0 .368.12.368.362v2.52c.375-.199.75-.37 1.143-.512.91-.328 1.852-.47 3.017-.482l-.283.25a1.14 1.14 0 0 0-.385.858v.784q.002.214.083.394c-.294.02-.56.052-.806.096l.01.029c.436 1.35 1.099 3.357 1.099 3.357.082.254-.013.386-.273.386h-2.01a.524.524 0 0 1-.532-.386l-.77-2.338q-.142.09-.293.188v2.174q.002.362-.367.362h-1.665a.95.95 0 0 0 .23-.6l.083-1.39v-.007c.008-.165-.001-.484-.243-.718-.248-.24-.564-.225-.748-.194l-.033.005v-.355l.033.006c.197.035.521.046.77-.208.23-.234.24-.544.233-.713v-.009l-.083-1.388a.93.93 0 0 0-.317-.685 1.3 1.3 0 0 0-.382-.209l-.093-.032-.161-.054V7.362q0-.362.354-.362zM3.919 10.868c-.556 0-.74.423-.74 1.132s.184 1.132.74 1.132.738-.423.738-1.132-.182-1.132-.738-1.132"/></svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M13.004 1.646c1.983 0 3.668.678 4.824 2.135q.17.215.32.446.694.46 1.24 1.146C20.512 6.787 21 8.767 21 11.103c0 1.856-.31 3.49-.999 4.795q.105.073.201.163c.306.288.483.648.595.967l.007.019.006.02c.144.46.24 1.13-.137 1.75a1.8 1.8 0 0 1-.263.335q.031.196.017.413c-.054.732-.522 1.223-.926 1.516H19.5c-.3.218-.73.458-1.268.458-.311 0-.58-.078-.807-.201-.23.123-.5.201-.81.201-.515 0-.955-.24-1.256-.458l-.013-.01c-.333-.249-.735-.648-.873-1.224a2.6 2.6 0 0 1-.673-.357l-.014-.01a2.6 2.6 0 0 1-.617-.64 6 6 0 0 1-.48-.128v2.168c0 .439-.108.995-.543 1.428-.429.427-.972.53-1.4.53H6.48c-.43 0-.968-.111-1.39-.541a1.87 1.87 0 0 1-.506-1.074 1.8 1.8 0 0 1-1.053-.517c-.421-.43-.53-.978-.53-1.417V3.86c0-.44.109-.987.53-1.418s.96-.54 1.389-.54h4.267c.348 0 .767.067 1.14.315.798-.376 1.7-.57 2.678-.57m0 1.193c-1.223 0-2.246.357-3.045.993 0-.483-.25-.738-.773-.738H4.918q-.748 0-.75.763V19.29q.002.763.75.764h4.268c.523 0 .773-.255.773-.764v-4.1c.81.625 1.814 1.005 2.985 1.018.007-.19.048-.377.107-.552.086-.277.218-.548.432-.753a1.17 1.17 0 0 1 .954-.318 1.25 1.25 0 0 1 .267-.548c.304-.355.749-.455 1.165-.455.411 0 .858.1 1.16.458q.064.076.112.156c.744-1.115 1.129-2.685 1.129-4.684 0-4.38-1.847-6.672-5.266-6.672m2.865 11.34c-.684 0-.955.332-.873 1.008l.047.248-.224-.119a1.2 1.2 0 0 0-.508-.142c-.341 0-.566.214-.707.676-.213.628.024.995.696 1.114l.248.036-.177.201c-.483.474-.448.9.106 1.316.212.153.413.237.578.237.271 0 .507-.19.696-.557l.118-.225.118.225c.189.367.414.557.685.557.189 0 .377-.083.59-.237.554-.403.578-.842.106-1.328L17.203 17l.236-.036c.684-.119.896-.486.696-1.126-.154-.438-.378-.651-.697-.651-.153 0-.318.047-.518.141l-.236.107.035-.248c.106-.676-.177-1.008-.85-1.008m-4.485-6.877c1.173 0 1.56.893 1.56 2.386s-.387 2.388-1.56 2.388-1.56-.894-1.56-2.388.387-2.386 1.56-2.386"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M11.815.056a.33.33 0 0 1 .37 0c.13.088.2.95.34 2.673l.047.599h3.312a4.745 4.745 0 0 1 4.745 4.745v.818h-1.637v-.818a3.11 3.11 0 0 0-3.109-3.11h-3.175c.147 1.658.295 2.622.726 3.41a5.24 5.24 0 0 0 2.026 2.05c.969.545 2.214.667 4.702.91l1.366.133c1.553.151 2.33.227 2.417.357a.32.32 0 0 1-.003.364c-.088.128-.866.192-2.422.32l-.891.073v3.347a4.745 4.745 0 0 1-4.746 4.746h-3.315l-.044.56c-.139 1.748-.21 2.622-.34 2.711a.33.33 0 0 1-.369 0c-.13-.088-.2-.963-.339-2.711l-.045-.56H8.03a4.746 4.746 0 0 1-4.746-4.746v-.818H4.92v.818a3.11 3.11 0 0 0 3.11 3.11h3.269c-.15-1.72-.297-2.713-.743-3.52a5.24 5.24 0 0 0-2.064-2.057c-.987-.543-2.254-.646-4.786-.853l-1.227-.1c-1.555-.128-2.333-.192-2.422-.32a.325.325 0 0 1-.003-.364c.087-.13.864-.206 2.417-.357l.812-.08V8.073a4.746 4.746 0 0 1 4.746-4.745h3.399l.048-.599c.14-1.722.209-2.585.34-2.673m7.177 12.66c-1.697.152-2.682.303-3.484.744a5.24 5.24 0 0 0-2.065 2.057c-.445.807-.592 1.8-.741 3.52h3.182a3.11 3.11 0 0 0 3.108-3.11zM8.029 4.964a3.11 3.11 0 0 0-3.11 3.109v3.152c1.79-.184 2.803-.342 3.62-.803a5.24 5.24 0 0 0 2.027-2.048c.432-.79.58-1.753.727-3.41z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M19 14h-2.417a5 5 0 0 1-9.166 0H5v4a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4Zm0-8a1 1 0 0 0-.898-.995L18 5H6a1 1 0 0 0-1 1v6h3.126a1 1 0 0 1 .969.751 3.001 3.001 0 0 0 5.81 0l.056-.16a1 1 0 0 1 .913-.591H19V6Zm2 12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12l.154.004A3 3 0 0 1 21 6v12Z"/></svg>

Before

Width:  |  Height:  |  Size: 387 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#111822" d="M57.666 33.416q-.001-.21-.022-.419L57.6 33H44a4.33 4.33 0 0 0-3.466 1.734l-.8 1.066a6.34 6.34 0 0 1-5.068 2.533h-5.333a6.33 6.33 0 0 1-5.066-2.533l-.8-1.066A4.33 4.33 0 0 0 20 33H6.4l-.045-.003a4 4 0 0 0-.022.42v11.916a4.333 4.333 0 0 0 4.333 4.333h42.667a4.333 4.333 0 0 0 4.333-4.333V33.416Zm-38.57-19.083a4.33 4.33 0 0 0-3.764 2.184L7.057 31H20c1.994 0 3.87.939 5.067 2.533l.8 1.067a4.33 4.33 0 0 0 3.466 1.733h5.333a4.33 4.33 0 0 0 3.467-1.733l.8-1.067A6.34 6.34 0 0 1 44 31h12.944l-8.277-14.483a4.33 4.33 0 0 0-3.762-2.184h-25.81Zm40.57 31a6.333 6.333 0 0 1-6.333 6.333H10.666a6.333 6.333 0 0 1-6.333-6.333V33.416c0-1.102.288-2.185.835-3.142l8.428-14.75a6.33 6.33 0 0 1 5.5-3.19h25.809a6.33 6.33 0 0 1 5.499 3.19l8.427 14.75h.001a6.33 6.33 0 0 1 .834 3.142v11.917Z"/></svg>

Before

Width:  |  Height:  |  Size: 875 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"><path fill="#000" fill-rule="evenodd" d="M14.5 8a1 1 0 0 1 .912.59l4.5 10a1 1 0 0 1-1.824.82L16.554 16h-4.108l-1.534 3.41a1 1 0 0 1-1.824-.82l4.5-10A1 1 0 0 1 14.5 8m-1.153 6h2.306L14.5 11.437zM5.053.106a1 1 0 0 1 1.342.447L7.118 2H11a1 1 0 0 1 0 2H9.92c-.28 1.765-1.27 3.24-2.393 4.398a17 17 0 0 0 2.605 1.562q.129.062.197.092l.005.002.049.022.01.005a1 1 0 0 1-.787 1.838h-.002l-.027-.013-.068-.03-.242-.113A19 19 0 0 1 6 9.75a19 19 0 0 1-3.509 2.125l-.068.03-.027.013h-.002a1 1 0 0 1-.788-1.838l.011-.005.049-.022.005-.002q.069-.03.197-.092a17 17 0 0 0 2.604-1.562C3.348 7.239 2.36 5.764 2.08 4H1a1 1 0 0 1 0-2h3.882l-.277-.553A1 1 0 0 1 5.053.106M4.118 4c.27 1.151.98 2.193 1.882 3.1.901-.907 1.612-1.95 1.882-3.1z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 819 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z"/></svg>

Before

Width:  |  Height:  |  Size: 502 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1"/></svg>

Before

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

-3
View File
@@ -47,9 +47,6 @@ module.exports = function (api) {
production: {
plugins: ['transform-remove-console'],
},
test: {
plugins: ['@babel/plugin-transform-class-static-block'],
},
},
}
}
+3 -3
View File
@@ -4,13 +4,13 @@ Install dependencies:
```bash
cd bskyembed
pnpm
yarn
```
Run the dev server:
```bash
pnpm dev
yarn dev
```
You can see the embed homepage at http://localhost:5173
@@ -20,7 +20,7 @@ You can see the embed homepage at http://localhost:5173
In another terminal window, run the snippet dev script:
```bash
pnpm dev-snippet
yarn dev-snippet
```
You can then see the testbed page at http://localhost:5173/test
+2 -8
View File
@@ -1,12 +1,10 @@
// @ts-check
import js from '@eslint/js'
import {defineConfig} from 'eslint/config'
import tseslint from 'typescript-eslint'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import {importX} from 'eslint-plugin-import-x'
import globals from 'globals'
export default defineConfig(
export default tseslint.config(
// Global ignores
{
ignores: ['dist/**', 'node_modules/**'],
@@ -16,11 +14,7 @@ export default defineConfig(
js.configs.recommended,
// TypeScript rules with type checking
tseslint.configs.recommendedTypeChecked,
// import-x
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
...tseslint.configs.recommendedTypeChecked,
// Main configuration
{
+8 -12
View File
@@ -7,33 +7,29 @@
"dev-snippet": "tsc --project tsconfig.snippet.json && serve -s dist -p 3000 -n",
"build": "tsc && vite build",
"build-snippet": "tsc --project tsconfig.snippet.json",
"analyze": "ANALYZE=1 vite build",
"lint": "eslint --cache src",
"typecheck": "tsc --noEmit",
"format": "prettier -w src"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@atproto/api": "^0.15.25",
"preact": "^10.4.8"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@preact/preset-vite": "^2.10.2",
"@vitejs/plugin-legacy": "^8.0.1",
"@vitejs/plugin-legacy": "^7.0.0",
"@eslint/js": "^9.18.0",
"autoprefixer": "^10.4.19",
"eslint": "^9.18.0",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-simple-import-sort": "^13.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.14.0",
"postcss": "^8.4.38",
"prettier": "^3.8.3",
"typescript-eslint": "^8.20.0",
"serve": "^14.2.5",
"tailwindcss": "^3.4.3",
"terser": "^5.43.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.12",
"vite-bundle-analyzer": "^1.3.8"
"typescript": "^5.8.3",
"vite": "^7.0.4",
"vite-tsconfig-paths": "^5.1.4"
},
"resolutions": {
"@types/estree": "1.0.6"
-4655
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
allowBuilds:
'@sentry/cli': true
'core-js': true
'esbuild': true
'unrs-resolver': true
minimumReleaseAgeExclude:
- '@atproto/*'
- '@bsky.app/*'
-1
View File
@@ -416,7 +416,6 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
controls
playsinline
preload="metadata"
// @ts-expect-error https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video#loading
loading="lazy"
aria-label={content.alt || undefined}
onClickCapture={evt => evt.stopPropagation()}
+5 -3
View File
@@ -1,10 +1,11 @@
{
"compilerOptions": {
"target": "ES2015",
"lib": ["DOM", "ESNext"],
"target": "ES5",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["vite/client"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
@@ -16,6 +17,7 @@
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"downlevelIteration": true
},
"include": ["src", "snippet", "vite.config.ts", "*.config.cjs", "eslint.config.mjs"]
"include": ["src", "snippet", "vite.config.ts", "*.config.cjs", ".eslintrc.cjs"]
}
+2 -3
View File
@@ -1,9 +1,8 @@
{
"compilerOptions": {
"target": "ES2015",
"lib": ["DOM", "ESNext"],
"target": "ES5",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"strict": true,
"rootDir": "snippet",
"outDir": "dist"
},
"include": ["snippet"]
+3 -8
View File
@@ -1,10 +1,10 @@
import fs from 'node:fs'
import {resolve} from 'node:path'
import {preact} from '@preact/preset-vite'
import preact from '@preact/preset-vite'
import legacy from '@vitejs/plugin-legacy'
import type {Plugin, UserConfig} from 'vite'
import {analyzer} from 'vite-bundle-analyzer'
import paths from 'vite-tsconfig-paths'
/**
* World's hackiest router, for dev only. Serves `/post.html` to requests that start with `/embed/`
@@ -38,13 +38,11 @@ function devOnlyRouter(): Plugin {
const config: UserConfig = {
plugins: [
preact(),
paths(),
legacy({
targets: ['defaults', 'not IE 11'],
}),
devOnlyRouter(),
analyzer({
enabled: process.env.ANALYZE === '1',
}),
],
build: {
assetsDir: 'static',
@@ -55,9 +53,6 @@ const config: UserConfig = {
},
},
},
resolve: {
tsconfigPaths: true,
},
}
export default config
+3516
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"main": "index.ts",
"scripts": {
"test": "yarn run test:unit && yarn run test:e2e",
"test": "npm run test:unit && npm run test:e2e",
"test:e2e": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
"test:unit": "node --loader ts-node/esm --test ./src/*.test.ts",
"build": "tsc"
+10 -19
View File
@@ -3,38 +3,29 @@
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.1.3",
"onFail": "warn"
}
},
"scripts": {
"start": "tsx ./src/bin.ts",
"dev": "LOG_ENABLED=true LOG_LEVEL=debug node --watch-path ./src --import tsx ./src/bin.ts",
"start": "node --loader ts-node/esm ./src/bin.ts",
"dev": "node --watch-path ./src --loader ts-node/esm ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/",
"install-fonts": "tsx scripts/install-fonts.ts",
"typecheck": "tsc --noEmit"
"install-fonts": "node --loader ts-node/esm scripts/install-fonts.ts"
},
"dependencies": {
"@atproto/api": "0.20.6",
"@atproto/common": "^0.6.1",
"@atproto/api": "0.12.19-next.0",
"@atproto/common": "^0.4.0",
"@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2",
"express-prom-bundle": "^7.0.0",
"http-terminator": "^3.2.0",
"pino": "^9.2.0",
"prom-client": "^15.1.3",
"react": "^19.2.6",
"satori": "^0.26.0",
"react": "^18.3.1",
"satori": "^0.10.13",
"twemoji": "^14.0.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^24.12.2",
"@types/react": "^19.2.15",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
"@types/node": "^20.14.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
}
}
-2029
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
nodeLinker: 'hoisted'
autoInstallPeers: true # default: true
strictPeerDependencies: false # default: false
trustPolicy: 'no-downgrade' # default: off
trustPolicyIgnoreAfter: 10080 # 7 days, default: undefined
minimumReleaseAgeExclude:
- '@atproto/*'
- '@bsky.app/*'
allowBuilds:
cbor-extract: true
esbuild: true
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
import cluster, {type Worker} from 'node:cluster'
import cluster, {Worker} from 'node:cluster'
import {envInt} from '@atproto/common'
-121
View File
@@ -1,121 +0,0 @@
import {Img} from './Img.js'
const SCALE = 3
const BASE_SIZE = 120
const PADDING = 4
const CONTENT_SCALE = (BASE_SIZE - PADDING * 2) / BASE_SIZE
export const AVATAR_BUBBLES_SIZE = BASE_SIZE * SCALE
type BubbleConfig = {size: number; x: number; y: number}
const LAYOUTS: Record<1 | 2 | 3 | 4, BubbleConfig[]> = {
1: [
{size: 76, x: 0, y: 0},
{size: 76, x: 44, y: 44},
],
2: [
{size: 76, x: 0, y: 0},
{size: 76, x: 44, y: 44},
],
3: [
{size: 68, x: 0.5, y: 0},
{size: 56, x: 40.5, y: 64},
{size: 46, x: 73.5, y: 20},
],
4: [
{size: 68, x: 1, y: 1},
{size: 56, x: 63, y: 52},
{size: 42, x: 17, y: 77},
{size: 32, x: 75, y: 12},
],
}
const PERSON_PATH =
'M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867ZM12.233 12.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z'
export function AvatarBubbles(props: {images: (Buffer | null)[]}) {
const count = Math.min(props.images.length, 4) as 1 | 2 | 3 | 4
const layout = LAYOUTS[count] ?? LAYOUTS[2]
const bubbles = layout.map((bubble, i) => ({
...bubble,
image: props.images[i] ?? null,
}))
// For the 2-avi layout, render in reverse so the first (top-left) paints last (on top)
if (count <= 2) {
bubbles.reverse()
}
return (
<div
style={{
display: 'flex',
width: AVATAR_BUBBLES_SIZE,
height: AVATAR_BUBBLES_SIZE,
position: 'relative',
}}>
{bubbles.map((bubble, i) => (
<AvatarBubble
key={i}
image={bubble.image}
size={bubble.size * CONTENT_SCALE * SCALE}
x={bubble.x * CONTENT_SCALE * SCALE + PADDING * SCALE}
y={bubble.y * CONTENT_SCALE * SCALE + PADDING * SCALE}
/>
))}
</div>
)
}
function AvatarBubble(props: {
image: Buffer | null
size: number
x: number
y: number
}) {
return (
<div
style={{
display: 'flex',
position: 'absolute',
left: props.x,
top: props.y,
width: props.size,
height: props.size,
borderRadius: '50%',
overflow: 'hidden',
}}>
{props.image ? (
<Img width="100%" height="100%" src={props.image} />
) : (
<Placeholder size={props.size} />
)}
</div>
)
}
function Placeholder(props: {size: number}) {
const iconSize = props.size * 0.5
const offset = (props.size - iconSize) / 2
return (
<div
style={{
display: 'flex',
width: props.size,
height: props.size,
backgroundColor: '#b0b0b0',
alignItems: 'center',
justifyContent: 'center',
}}>
<svg
width={iconSize}
height={iconSize}
viewBox="0 0 24 24"
style={{position: 'absolute', left: offset, top: offset}}>
<path d={PERSON_PATH} fill="white" />
</svg>
</div>
)
}
+2
View File
@@ -1,3 +1,5 @@
import React from 'react'
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
return (
<svg
-526
View File
@@ -1,526 +0,0 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text */
import {ChatBskyGroupDefs} from '@atproto/api'
import {Img} from './Img.js'
export const CHAT_INVITE_HEIGHT = 630
export const CHAT_INVITE_WIDTH = 1200
const PADDING = 64
export function ChatInvite({
preview,
ownerImage,
}: {
preview: ChatBskyGroupDefs.JoinLinkPreviewView
ownerImage: Buffer<ArrayBufferLike> | null
}) {
return (
<div
style={{
width: CHAT_INVITE_WIDTH,
height: CHAT_INVITE_HEIGHT,
display: 'flex',
position: 'relative',
backgroundColor: '#006AFF',
}}>
<Background
style={{
position: 'absolute',
inset: 0,
}}
/>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
padding: PADDING,
color: 'white',
fontFamily: 'Inter',
}}>
{/* Avatar */}
<div
style={{
display: 'flex',
width: 160,
height: 160,
borderRadius: '50%',
overflow: 'hidden',
}}>
{ownerImage && <Img src={ownerImage} width="100%" height="100%" />}
</div>
{/* Spacer pushes the text block to the bottom-left */}
<div style={{display: 'flex', flex: 1}} />
{/* Group Chat row */}
<div
style={{
display: 'flex',
alignItems: 'center',
fontSize: 26,
fontWeight: 600,
lineHeight: 1.3,
}}>
<ChatIcon style={{marginRight: 8, marginTop: -2}} />
Group Chat
</div>
{/* Chat name */}
<div
style={{
display: 'block',
fontSize: 80,
fontWeight: 700,
lineHeight: 1.1,
marginTop: 6,
minHeight: 130,
maxWidth: 1000,
wordBreak: 'break-word',
lineClamp: 2,
textOverflow: 'ellipsis',
overflow: 'hidden',
}}>
{preview.name}
</div>
{/* By @handle */}
<div
style={{
display: 'block',
fontSize: 34,
fontWeight: 400,
marginTop: 12,
lineHeight: 1.3,
lineClamp: 1,
textOverflow: 'ellipsis',
overflow: 'hidden',
maxWidth: 800,
wordBreak: 'break-all',
}}>
{'By @' + preview.owner.handle}
</div>
</div>
</div>
)
}
function ChatIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<path
d="M9.99935 14.3334C10.9198 14.3334 11.666 15.0796 11.666 16.0001C11.666 16.9206 10.9198 17.6667 9.99935 17.6667C9.07887 17.6667 8.33268 16.9206 8.33268 16.0001C8.33268 15.0796 9.07887 14.3334 9.99935 14.3334Z"
fill="white"
/>
<path
d="M15.9993 14.3334C16.9198 14.3334 17.666 15.0796 17.666 16.0001C17.666 16.9206 16.9198 17.6667 15.9993 17.6667C15.0789 17.6667 14.3327 16.9206 14.3327 16.0001C14.3327 15.0796 15.0789 14.3334 15.9993 14.3334Z"
fill="white"
/>
<path
d="M21.9993 14.3334C22.9198 14.3334 23.666 15.0796 23.666 16.0001C23.666 16.9206 22.9198 17.6667 21.9993 17.6667C21.0789 17.6667 20.3327 16.9206 20.3327 16.0001C20.3327 15.0796 21.0789 14.3334 21.9993 14.3334Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.9993 2.66675C23.3631 2.66675 29.3327 8.63628 29.3327 16.0001C29.3327 23.3639 23.3631 29.3334 15.9993 29.3334C14.0352 29.3334 12.167 28.9082 10.485 28.1433L4.24414 29.3113C3.80802 29.3927 3.35993 29.2513 3.04883 28.935C2.73759 28.6183 2.60409 28.1668 2.69336 27.7319L3.93424 21.6824C3.12049 19.9572 2.66602 18.0301 2.66602 16.0001C2.66602 8.63629 8.63556 2.66676 15.9993 2.66675ZM15.9993 5.33341C10.1083 5.33343 5.33268 10.1091 5.33268 16.0001C5.33268 17.7656 5.76102 19.4272 6.51758 20.8907C6.65753 21.1615 6.69993 21.4723 6.63867 21.7709L5.70378 26.3243L10.4212 25.4415C10.7058 25.3883 11.0005 25.4298 11.2598 25.5587C12.6861 26.2674 14.2941 26.6667 15.9993 26.6667C21.8904 26.6667 26.666 21.8911 26.666 16.0001C26.666 10.109 21.8904 5.33341 15.9993 5.33341Z"
fill="white"
/>
</svg>
)
}
function Background(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
width="1200"
height="630"
viewBox="0 0 1200 630"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<g clip-path="url(#clip0_3134_11977)">
<path
d="M938.799 522.018C944.511 526.47 950.654 535.496 952.91 540.34C955.166 535.496 961.309 526.47 967.021 522.018C971.142 518.806 977.82 516.32 977.82 524.229C977.82 525.808 976.948 537.498 976.436 539.396C974.658 545.993 968.178 547.676 962.413 546.657C972.489 548.438 975.053 554.335 969.517 560.232C957.062 573.5 952.91 553.134 952.91 553.134C952.91 553.134 948.758 573.5 936.303 560.232C930.768 554.335 933.331 548.438 943.407 546.657C937.642 547.676 931.162 545.993 929.384 539.396C928.872 537.498 928 525.808 928 524.229C928 516.32 934.678 518.806 938.799 522.018Z"
fill="white"
/>
<path
d="M1008.88 541.392C1012.31 542.653 1014.13 545.565 1014.13 548.825C1014.13 554.346 1010.53 557.693 1003.63 557.693H989.527V526.743H1003.16C1009.73 526.743 1012.9 530.177 1012.9 534.742C1012.9 537.784 1011.55 540.001 1008.88 541.392ZM1002.74 531.568H995.244V539.653H1002.74C1005.66 539.653 1007.23 538.089 1007.23 535.48C1007.23 533.09 1005.62 531.568 1002.74 531.568ZM995.244 552.824H1003.33C1006.55 552.824 1008.29 551.303 1008.29 548.564C1008.29 545.695 1006.64 544.261 1003.33 544.261H995.244V552.824Z"
fill="white"
/>
<path
d="M1022.06 557.693H1016.72V526.743H1022.06V557.693Z"
fill="white"
/>
<path
d="M1039.75 547.825V535.263H1045.09V557.693H1039.92V554.432C1038.27 556.954 1035.98 558.214 1033.06 558.214C1028.44 558.214 1025.44 555.345 1025.44 550.129V535.263H1030.77V549.216C1030.77 552.042 1032.13 553.476 1034.88 553.476C1037.46 553.476 1039.75 551.52 1039.75 547.825Z"
fill="white"
/>
<path
d="M1069.37 546.869V548.173H1053.03C1053.41 552.042 1055.48 553.954 1058.7 553.954C1061.16 553.954 1062.81 552.868 1063.7 550.738H1068.82C1067.68 555.302 1063.87 558.214 1058.66 558.214C1055.4 558.214 1052.77 557.127 1050.78 554.998C1048.79 552.868 1047.78 550.042 1047.78 546.478C1047.78 542.957 1048.75 540.132 1050.74 537.958C1052.73 535.828 1055.31 534.742 1058.57 534.742C1061.88 534.742 1064.5 535.872 1066.45 538.089C1068.4 540.305 1069.37 543.261 1069.37 546.869ZM1058.53 539.001C1055.61 539.001 1053.58 540.74 1053.07 544.348H1064.04C1063.57 541.088 1061.62 539.001 1058.53 539.001Z"
fill="white"
/>
<path
d="M1081.2 558.301C1074.85 558.301 1071.5 555.737 1071.2 550.564H1076.41C1076.71 553.346 1078.06 554.389 1081.28 554.389C1084.16 554.389 1085.6 553.476 1085.6 551.694C1085.6 550.086 1084.59 549.303 1081.33 548.738L1078.83 548.303C1074.04 547.478 1071.67 545.217 1071.67 541.522C1071.67 537.306 1074.93 534.742 1080.73 534.742C1086.96 534.742 1090.18 537.263 1090.39 542.348H1085.35C1085.22 539.61 1083.65 538.654 1080.73 538.654C1078.19 538.654 1076.92 539.523 1076.92 541.262C1076.92 542.827 1078.02 543.522 1080.56 544L1083.32 544.435C1088.61 545.435 1090.9 547.434 1090.9 551.259C1090.9 555.78 1087.34 558.301 1081.2 558.301Z"
fill="white"
/>
<path
d="M1114.31 557.693H1108.22L1101.86 547.26L1098.56 550.651V557.693H1093.31V526.743H1098.56V544.565L1107.45 535.263H1113.81L1105.55 543.739L1114.31 557.693Z"
fill="white"
/>
<path
d="M1128.25 540.914L1130.03 535.263H1135.62L1127.19 559.518C1126.3 561.996 1125.2 563.778 1123.8 564.778C1122.41 565.778 1120.42 566.256 1117.79 566.256C1116.9 566.256 1116.14 566.212 1115.46 566.125V561.822H1117.49C1119.91 561.822 1121.09 560.301 1121.09 558.214C1121.09 557.171 1120.75 555.65 1120.08 553.694L1113.72 535.263H1119.48L1121.26 540.87C1122.58 545.087 1123.72 549.26 1124.74 553.389C1125.67 549.825 1126.85 545.652 1128.25 540.914Z"
fill="white"
/>
<g filter="url(#filter0_d_3134_11977)">
<rect
x="236"
y="130"
width="145.6"
height="145.6"
rx="72.8"
fill="#4291FF"
shape-rendering="crispEdges"
/>
<path
d="M308.006 202.8C321.082 202.8 331.09 210.636 335.154 221.624C336.316 224.765 335.52 227.861 333.674 230.062C331.874 232.207 329.095 233.512 326.102 233.512H289.909C286.916 233.512 284.138 232.206 282.339 230.062C280.492 227.861 279.696 224.765 280.858 221.624C284.922 210.636 294.929 202.8 308.006 202.8ZM308.001 168.675C316.482 168.675 323.357 175.551 323.357 184.032C323.357 192.513 316.482 199.387 308.001 199.387C299.52 199.387 292.644 192.513 292.644 184.032C292.644 175.551 299.52 168.675 308.001 168.675Z"
fill="#006AFF"
/>
</g>
<g filter="url(#filter1_d_3134_11977)">
<rect
x="316"
y="-60"
width="191.1"
height="191.1"
rx="95.55"
fill="#4291FF"
shape-rendering="crispEdges"
/>
<path
d="M412.006 35.5498C429.441 35.55 442.784 45.9985 448.202 60.6484C449.751 64.8366 448.691 68.9642 446.229 71.8984C443.829 74.7581 440.125 76.4999 436.135 76.5H387.876C383.886 76.5 380.182 74.7581 377.783 71.8984C375.32 68.9642 374.26 64.8366 375.809 60.6484C381.227 45.9983 394.571 35.5498 412.006 35.5498ZM411.999 -9.9502C423.307 -9.95 432.474 -0.782514 432.474 10.5254C432.474 21.8332 423.307 30.9998 411.999 31C400.691 31 391.524 21.8333 391.524 10.5254C391.524 -0.782633 400.691 -9.9502 411.999 -9.9502Z"
fill="#006AFF"
/>
</g>
<g filter="url(#filter2_d_3134_11977)">
<rect
x="472"
y="84"
width="246"
height="246"
rx="123"
fill="#4291FF"
shape-rendering="crispEdges"
/>
<path
d="M596.01 207C618.454 207.001 635.631 220.449 642.606 239.308C644.6 244.7 643.235 250.014 640.065 253.792C636.976 257.472 632.207 259.714 627.071 259.714H564.949C559.812 259.714 555.044 257.473 551.955 253.792C548.785 250.014 547.42 244.7 549.414 239.308C556.388 220.449 573.566 207 596.01 207ZM596.002 148.428C610.558 148.428 622.359 160.229 622.359 174.786C622.359 189.342 610.558 201.143 596.002 201.143C581.445 201.143 569.644 189.342 569.644 174.786C569.644 160.229 581.445 148.428 596.002 148.428Z"
fill="#006AFF"
/>
</g>
<g filter="url(#filter3_d_3134_11977)">
<rect
x="870"
y="-22"
width="308.195"
height="308.195"
rx="154.098"
fill="#4291FF"
shape-rendering="crispEdges"
/>
<path
d="M1024.01 132.098C1052.13 132.098 1073.65 148.948 1082.39 172.574C1084.89 179.329 1083.18 185.987 1079.21 190.719C1075.34 195.331 1069.36 198.14 1062.93 198.14H985.098C978.663 198.14 972.689 195.331 968.819 190.719C964.848 185.987 963.138 179.329 965.636 172.574C974.374 148.947 995.894 132.098 1024.01 132.098ZM1024 58.7178C1042.24 58.7178 1057.02 73.5023 1057.02 91.7393C1057.02 109.976 1042.24 124.76 1024 124.76C1005.76 124.76 990.981 109.976 990.981 91.7393C990.981 73.5024 1005.76 58.7179 1024 58.7178Z"
fill="#006AFF"
/>
</g>
<g filter="url(#filter4_d_3134_11977)">
<rect
x="546"
y="-290"
width="374"
height="374"
rx="187"
fill="#4291FF"
shape-rendering="crispEdges"
/>
</g>
<g filter="url(#filter5_d_3134_11977)">
<rect
x="728"
y="222"
width="180"
height="180"
rx="90"
fill="#4291FF"
shape-rendering="crispEdges"
/>
<path
d="M818.006 312C834.429 312 846.998 321.841 852.101 335.64C853.56 339.585 852.561 343.473 850.242 346.237C847.981 348.931 844.492 350.571 840.734 350.571H795.278C791.519 350.571 788.03 348.931 785.77 346.237C783.451 343.473 782.453 339.585 783.912 335.64C789.015 321.841 801.584 312 818.006 312ZM817.999 269.143C828.651 269.143 837.286 277.778 837.286 288.429C837.285 299.08 828.651 307.714 817.999 307.714C807.348 307.714 798.714 299.08 798.714 288.429C798.714 277.778 807.348 269.143 817.999 269.143Z"
fill="#006AFF"
/>
</g>
<g filter="url(#filter6_d_3134_11977)">
<rect
x="1146"
y="220"
width="178"
height="178"
rx="89"
fill="#4291FF"
shape-rendering="crispEdges"
/>
</g>
</g>
<defs>
<filter
id="filter0_d_3134_11977"
x="204"
y="114"
width="209.6"
height="209.6"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter1_d_3134_11977"
x="284"
y="-76"
width="255.1"
height="255.1"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter2_d_3134_11977"
x="440"
y="68"
width="310"
height="310"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter3_d_3134_11977"
x="838"
y="-38"
width="372.195"
height="372.195"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter4_d_3134_11977"
x="514"
y="-306"
width="438"
height="438"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter5_d_3134_11977"
x="696"
y="206"
width="244"
height="244"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter6_d_3134_11977"
x="1114"
y="204"
width="242"
height="242"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<clipPath id="clip0_3134_11977">
<rect width="1200" height="630" fill="white" />
</clipPath>
</defs>
</svg>
)
}
+2
View File
@@ -1,3 +1,5 @@
import React from 'react'
// @NOTE satori does not currently support webp, see vercel/satori#273
function detectMime(buf: Buffer): string {
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
+3 -8
View File
@@ -1,9 +1,9 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text */
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import React from 'react'
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {Butterfly} from './Butterfly.js'
import {Img} from './Img.js'
import * as bsky from '../types/bsky/index.js'
export const STARTERPACK_HEIGHT = 630
export const STARTERPACK_WIDTH = 1200
@@ -18,10 +18,7 @@ export function StarterPack(props: {
images: Map<string, Buffer>
}) {
const {starterPack, images} = props
const record = bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
starterPack.record,
AppBskyGraphStarterpack.isRecord,
)
const record = AppBskyGraphStarterpack.isRecord(starterPack.record)
? starterPack.record
: null
const imagesArray = [...images.values()]
@@ -57,7 +54,6 @@ export function StarterPack(props: {
backgroundColor: 'black',
color: 'white',
fontFamily: 'Inter',
fontWeight: 700,
}}>
{/* image tiles */}
<div
@@ -138,7 +134,6 @@ export function StarterPack(props: {
fontSize: isLongTitle ? 55 : 65,
display: 'flex',
textAlign: 'center',
textWrap: 'balance',
}}>
{record?.name || 'Starter Pack'}
</div>
-4
View File
@@ -9,7 +9,6 @@ export type ServiceConfig = {
metricsPort: number
version?: string
appviewUrl: string
chatUrl: string
originVerify?: string
}
@@ -18,7 +17,6 @@ export type Environment = {
metricsPort?: number
version?: string
appviewUrl?: string
chatUrl?: string
originVerify?: string
}
@@ -28,7 +26,6 @@ export const readEnv = (): Environment => {
metricsPort: envInt('CARD_METRICS_PORT'),
version: envStr('CARD_VERSION'),
appviewUrl: envStr('CARD_APPVIEW_URL'),
chatUrl: envStr('CARD_CHAT_URL'),
originVerify: envStr('CARD_ORIGIN_VERIFY'),
}
}
@@ -39,7 +36,6 @@ export const envToCfg = (env: Environment): Config => {
metricsPort: env.metricsPort ?? 3001,
version: env.version,
appviewUrl: env.appviewUrl ?? 'https://api.bsky.app',
chatUrl: env.chatUrl ?? 'https://api.bsky.chat',
originVerify: env.originVerify,
}
return {
+8 -36
View File
@@ -3,72 +3,44 @@ import * as path from 'node:path'
import {fileURLToPath} from 'node:url'
import {AtpAgent} from '@atproto/api'
import {type FontWeight} from 'satori'
import {type Config} from './config.js'
import {Config} from './config.js'
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
export type AppContextOptions = {
cfg: Config
appviewAgent: AtpAgent
chatAgent: AtpAgent
fonts: {name: string; data: Buffer; weight?: FontWeight}[]
fonts: {name: string; data: Buffer}[]
}
export class AppContext {
cfg: Config
appviewAgent: AtpAgent
chatAgent: AtpAgent
fonts: {name: string; data: Buffer; weight?: FontWeight}[]
fonts: {name: string; data: Buffer}[]
abortController = new AbortController()
private opts: AppContextOptions
constructor(opts: AppContextOptions) {
this.opts = opts
this.cfg = opts.cfg
this.appviewAgent = opts.appviewAgent
this.chatAgent = opts.chatAgent
this.fonts = opts.fonts
constructor(private opts: AppContextOptions) {
this.cfg = this.opts.cfg
this.appviewAgent = this.opts.appviewAgent
this.fonts = this.opts.fonts
}
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
const chatAgent = new AtpAgent({service: cfg.service.chatUrl})
const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
const fontFiles = readdirSync(fontDirectory)
const fonts = fontFiles.map(file => {
return {
name: getName(file),
name: path.basename(file, path.extname(file)),
data: readFileSync(path.join(fontDirectory, file)),
weight: getWeight(file),
}
})
return new AppContext({
cfg,
appviewAgent,
chatAgent,
fonts,
...overrides,
})
}
}
function getName(file: string): string {
const base = path.basename(file, path.extname(file))
if (base.startsWith('Inter-')) return 'Inter'
return base
}
function getWeight(file: string): FontWeight {
switch (path.basename(file, path.extname(file))) {
case 'Inter-Regular':
return 400
case 'Inter-SemiBold':
return 600
case 'Inter-Bold':
default:
return 700
}
}
+4 -7
View File
@@ -19,13 +19,10 @@ export class CardService {
private terminator?: HttpTerminator
private metricsTerminator?: HttpTerminator
public app: express.Application
public ctx: AppContext
constructor(app: express.Application, ctx: AppContext) {
this.app = app
this.ctx = ctx
}
constructor(
public app: express.Application,
public ctx: AppContext,
) {}
static async create(cfg: Config): Promise<CardService> {
let app = express()
-69
View File
@@ -1,69 +0,0 @@
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
import satori from 'satori'
import {
AVATAR_BUBBLES_SIZE,
AvatarBubbles,
} from '../components/AvatarBubbles.js'
import {type AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {
getImage,
handler,
hideAvatarLabels,
originVerifyMiddleware,
} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/avatar-bubbles',
originVerifyMiddleware(ctx),
handler(async (req, res) => {
const didParam = req.query.did
const dids = (Array.isArray(didParam) ? didParam : [didParam]).filter(
(d): d is string => typeof d === 'string' && d.length > 0,
)
if (dids.length < 1 || dids.length > 4) {
return res.status(400).end('did param must contain 1-4 DIDs')
}
let profiles
try {
const result = await ctx.appviewAgent.api.app.bsky.actor.getProfiles({
actors: dids,
})
profiles = result.data.profiles
} catch (err) {
httpLogger.warn({err, dids}, 'could not fetch profiles')
return res.status(502).end('could not fetch profiles')
}
const images = await Promise.all(
dids.map(async did => {
const profile = profiles.find(p => p.did === did)
if (!profile?.avatar) return null
if (profile.labels?.some(l => hideAvatarLabels.has(l.val)))
return null
try {
return await getImage(profile.avatar)
} catch (err) {
httpLogger.warn({err, did}, 'could not fetch avatar image')
return null
}
}),
)
const svg = await satori(<AvatarBubbles images={images} />, {
fonts: ctx.fonts,
height: AVATAR_BUBBLES_SIZE,
width: AVATAR_BUBBLES_SIZE,
})
const output = await resvg.renderAsync(svg)
res.statusCode = 200
res.setHeader('content-type', 'image/png')
res.setHeader('cdn-tag', dids.join(','))
return res.end(output.asPng())
}),
)
}
-82
View File
@@ -1,82 +0,0 @@
import assert from 'node:assert'
import {type ChatBskyGroupDefs} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
import satori from 'satori'
import {
CHAT_INVITE_HEIGHT,
CHAT_INVITE_WIDTH,
ChatInvite,
} from '../components/ChatInvite.js'
import {type AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {loadEmojiAsSvg} from '../util.js'
import {
getImage,
handler,
hideAvatarLabels,
originVerifyMiddleware,
} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/chat-invite/:code',
originVerifyMiddleware(ctx),
handler(async (req, res) => {
const {code} = req.params
let preview: ChatBskyGroupDefs.JoinLinkPreviewView
try {
const result = await ctx.chatAgent.chat.bsky.group.getJoinLinkPreviews({
codes: [code],
})
const found = result.data.joinLinkPreviews[0]
if (!found) {
return res.status(404).end('not found')
}
preview = found
} catch (err) {
httpLogger.warn({err, code}, 'could not fetch chat invite preview')
return res.status(404).end('not found')
}
// Load the owner's avatar (if any, and not labeled).
let ownerImage: Buffer | null = null
const owner = preview.owner
const ownerHasSafeAvatar =
!!owner.avatar && !owner.labels?.some(l => hideAvatarLabels.has(l.val))
if (ownerHasSafeAvatar) {
try {
assert(owner.avatar)
ownerImage = await getImage(owner.avatar)
} catch (err) {
httpLogger.warn(
{err, code, did: owner.did},
'could not fetch owner image',
)
}
}
const svg = await satori(
<ChatInvite preview={preview} ownerImage={ownerImage} />,
{
fonts: ctx.fonts,
height: CHAT_INVITE_HEIGHT,
width: CHAT_INVITE_WIDTH,
loadAdditionalAsset: async (lang, text) => {
if (lang === 'emoji') {
return (await loadEmojiAsSvg(text)) ?? ''
}
return ''
},
},
)
const output = await resvg.renderAsync(svg)
res.statusCode = 200
res.setHeader('content-type', 'image/png')
res.setHeader('cdn-tag', owner.did)
return res.end(output.asPng())
}),
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import {type Express} from 'express'
import {Express} from 'express'
import {type AppContext} from '../context.js'
import {AppContext} from '../context.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
+2 -6
View File
@@ -1,8 +1,6 @@
import {type Express} from 'express'
import {Express} from 'express'
import {type AppContext} from '../context.js'
import {default as avatarBubbles} from './avatar-bubbles.js'
import {default as chatInvite} from './chat-invite.js'
import {AppContext} from '../context.js'
import {default as health} from './health.js'
import {default as starterPack} from './starter-pack.js'
@@ -11,7 +9,5 @@ export * from './util.js'
export default function (ctx: AppContext, app: Express) {
app = health(ctx, app) // GET /_health
app = starterPack(ctx, app) // GET /start/:actor/:rkey
app = chatInvite(ctx, app) // GET /chat-invite/:code
app = avatarBubbles(ctx, app) // GET /avatar-bubbles?dids=...
return app
}
+42 -21
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert'
import React from 'react'
import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
@@ -13,13 +14,7 @@ import {
import {type AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {loadEmojiAsSvg} from '../util.js'
import {
getImage,
handler,
hideAvatarLabels,
originVerifyMiddleware,
} from './util.js'
import * as bsky from '../types/bsky/index.js'
import {handler, originVerifyMiddleware} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
@@ -30,9 +25,9 @@ export default function (ctx: AppContext, app: Express) {
const uri = AtUri.make(actor, 'app.bsky.graph.starterpack', rkey)
let starterPack: AppBskyGraphDefs.StarterPackView
try {
const result = await ctx.appviewAgent.app.bsky.graph.getStarterPack({
starterPack: uri.toString(),
})
const result = await ctx.appviewAgent.api.app.bsky.graph.getStarterPack(
{starterPack: uri.toString()},
)
starterPack = result.data.starterPack
} catch (err) {
httpLogger.warn(
@@ -42,12 +37,12 @@ export default function (ctx: AppContext, app: Express) {
return res.status(404).end('not found')
}
const imageEntries = await Promise.all(
([starterPack.creator] as Array<bsky.profile.AnyProfileView>)
.concat((starterPack.listItemsSample ?? []).map(li => li.subject))
[starterPack.creator]
.concat(starterPack.listItemsSample.map(li => li.subject))
// has avatar
.filter(p => p.avatar)
// no sensitive labels
.filter(p => !p.labels?.some(l => hideAvatarLabels.has(l.val)))
.filter(p => !p.labels.some(l => hideAvatarLabels.has(l.val)))
.map(async p => {
try {
assert(p.avatar)
@@ -63,12 +58,7 @@ export default function (ctx: AppContext, app: Express) {
}),
)
const images = new Map(
imageEntries
.filter(
(entry): entry is readonly [string, Buffer<ArrayBuffer>] =>
entry[1] !== null,
)
.slice(0, 7),
imageEntries.filter(([_, image]) => image !== null).slice(0, 7),
)
const svg = await satori(
<StarterPack starterPack={starterPack} images={images} />,
@@ -78,9 +68,8 @@ export default function (ctx: AppContext, app: Express) {
width: STARTERPACK_WIDTH,
loadAdditionalAsset: async (code, text) => {
if (code === 'emoji') {
return (await loadEmojiAsSvg(text)) ?? ''
return await loadEmojiAsSvg(text)
}
return ''
},
},
)
@@ -92,3 +81,35 @@ export default function (ctx: AppContext, app: Express) {
}),
)
}
async function getImage(url: string) {
const response = await fetch(ensureJpeg(url))
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
if (response.status !== 200) return null
return Buffer.from(arrayBuf)
}
// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp).
// We want to ensure the image URLs we use are for jpegs, required for compat with satori.
function ensureJpeg(url: string) {
return url.replace(/(@[a-z]{3,5})?$/, '@jpeg')
}
const hideAvatarLabels = new Set([
'!hide',
'!warn',
'porn',
'sexual',
'nudity',
'sexual-figurative',
'graphic-media',
'gore',
'self-harm',
'sensitive',
'security',
'impersonation',
'scam',
'spam',
'misleading',
'inauthentic',
])
+2 -39
View File
@@ -1,11 +1,6 @@
import {
type ErrorRequestHandler,
type Request,
type RequestHandler,
type Response,
} from 'express'
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
import {type AppContext} from '../context.js'
import {AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
export type Handler = (req: Request, res: Response) => Awaited<void>
@@ -39,35 +34,3 @@ export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
}
return res.status(500).end('server error')
}
export async function getImage(url: string) {
const response = await fetch(ensureJpeg(url))
const arrayBuf = await response.arrayBuffer()
if (response.status !== 200) return null
return Buffer.from(arrayBuf)
}
// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp).
// We want to ensure the image URLs we use are for jpegs, required for compat with satori.
function ensureJpeg(url: string) {
return url.replace(/(@[a-z]{3,5})?$/, '@jpeg')
}
export const hideAvatarLabels = new Set([
'!hide',
'!warn',
'porn',
'sexual',
'nudity',
'sexual-figurative',
'graphic-media',
'gore',
'self-harm',
'sensitive',
'security',
'impersonation',
'scam',
'spam',
'misleading',
'inauthentic',
])
-50
View File
@@ -1,50 +0,0 @@
import {type ValidationResult} from '@atproto/lexicon'
export * as profile from './profile.js'
export * as starterPack from './starterPack.js'
/**
* Fast type checking without full schema validation, for use with data we
* trust, or for non-critical path use cases. Why? Our SDK's `is*` identity
* utils do not assert the type of the entire object, only the `$type` string.
*
* For full validation of the object schema, use the `validate` export from
* this file.
*
* Usage:
* ```ts
* import * as bsky from '#/types/bsky'
*
* if (bsky.dangerousIsType<AppBskyFeedPost.Record>(item, AppBskyFeedPost.isRecord)) {
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
* }
* ```
*/
export function dangerousIsType<R extends {$type?: string}>(
record: unknown,
identity: <V>(v: V) => v is V & {$type: NonNullable<R['$type']>},
): record is R {
return identity(record)
}
/**
* Fully validates the object schema, which has a performance cost.
*
* For faster checks with data we trust, like that from our app view, use the
* `dangerousIsType` export from this same file.
*
* Usage:
* ```ts
* import * as bsky from '#/types/bsky'
*
* if (bsky.validate(item, AppBskyFeedPost.validateRecord)) {
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
* }
* ```
*/
export function validate<R extends {$type?: string}>(
record: unknown,
validator: (v: unknown) => ValidationResult<R>,
): record is R {
return validator(record).success
}
-10
View File
@@ -1,10 +0,0 @@
import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api'
/**
* Matches any profile view exported by our SDK
*/
export type AnyProfileView =
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileView
| AppBskyActorDefs.ProfileViewDetailed
| ChatBskyActorDefs.ProfileViewBasic
-11
View File
@@ -1,11 +0,0 @@
import {AppBskyGraphDefs} from '@atproto/api'
export const isBasicView = AppBskyGraphDefs.isStarterPackViewBasic
export const isView = AppBskyGraphDefs.isStarterPackView
/**
* Matches any starter pack view exported by our SDK
*/
export type AnyStarterPackView =
| AppBskyGraphDefs.StarterPackViewBasic
| AppBskyGraphDefs.StarterPackView
+2 -5
View File
@@ -3,12 +3,9 @@
"target": "ESNext",
"module": "NodeNext",
"esModuleInterop": true,
"erasableSyntaxOnly": true,
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"rootDir": "./src",
"outDir": "dist",
"skipLibCheck": true
"outDir": "dist"
},
"include": ["./src/**/*"]
"include": ["./src/index.ts", "./src/bin.ts"]
}
+1638
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -10,13 +10,13 @@ like:
# install nodejs
nvm install
nvm use
npm install --global pnpm
npm install --global yarn
# setup tools and deps (in top level of this repo)
pnpm install --frozen-lockfile
yarn install --frozen-lockfile
# run pnpm web dev server, if you wanted
pnpm web
# run yarn web dev server, if you wanted
yarn web
```
Then build and copy over the big 'ol `bundle.web.js` file:
@@ -24,7 +24,7 @@ Then build and copy over the big 'ol `bundle.web.js` file:
```bash
# in the top level of this repo
pnpm build-web
yarn build-web
```
### Golang Daemon
-48
View File
@@ -1,48 +0,0 @@
package main
import (
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
// Helpers for extracting Open Graph metadata from post embeds. These feed
// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in
// jsonld.go. og:video has its own path because JSON-LD does not yet emit
// VideoObject (deferred — needs `duration` from the appview).
// videoMeta holds og:video meta tag data.
type videoMeta struct {
URL string
Type string
Width int64
Height int64
HasSize bool
}
// extractVideoMeta returns og:video metadata, or the zero value if there's
// no video embed. Respects embedHidden so og:video and og:image stay in sync.
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
if pv == nil || pv.Embed == nil || embedHidden {
return videoMeta{}
}
var v *appbsky.EmbedVideo_View
if pv.Embed.EmbedVideo_View != nil {
v = pv.Embed.EmbedVideo_View
} else if pv.Embed.EmbedRecordWithMedia_View != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
v = pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
}
if v == nil || v.Playlist == "" {
return videoMeta{}
}
out := videoMeta{
URL: v.Playlist,
Type: "application/vnd.apple.mpegurl",
}
if v.AspectRatio != nil {
out.Width = v.AspectRatio.Width
out.Height = v.AspectRatio.Height
out.HasSize = true
}
return out
}
-69
View File
@@ -1,69 +0,0 @@
package main
import (
"testing"
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
func TestExtractVideoMeta(t *testing.T) {
// Plain post — no video.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc", "hi")
if vm := extractVideoMeta(pv, false); vm.URL != "" {
t.Errorf("non-video post produced video meta: %+v", vm)
}
// Video embed with playlist + aspect ratio.
playlist := "https://video.bsky.app/playlist.m3u8"
pv = makePostView("alice.bsky.social", "did:plc:alice", "abc", "watch")
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedVideo_View: &appbsky.EmbedVideo_View{
Thumbnail: strPtr("https://cdn.bsky.app/thumb@jpg"),
Playlist: playlist,
AspectRatio: &appbsky.EmbedDefs_AspectRatio{Width: 16, Height: 9},
},
}
vm := extractVideoMeta(pv, false)
if vm.URL != playlist {
t.Errorf("URL wrong: %v", vm.URL)
}
if vm.Type != "application/vnd.apple.mpegurl" {
t.Errorf("Type wrong: %v", vm.Type)
}
if !vm.HasSize || vm.Width != 16 || vm.Height != 9 {
t.Errorf("aspect ratio not propagated: %+v", vm)
}
// Hidden embed gate suppresses video too.
if vm := extractVideoMeta(pv, true); vm.URL != "" {
t.Errorf("hidden embed should suppress video meta")
}
// Video inside record-with-media.
pv2 := makePostView("alice.bsky.social", "did:plc:alice", "abc", "quote w/ video")
pv2.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
Media: &appbsky.EmbedRecordWithMedia_View_Media{
EmbedVideo_View: &appbsky.EmbedVideo_View{Playlist: playlist},
},
},
}
vm2 := extractVideoMeta(pv2, false)
if vm2.URL != playlist {
t.Errorf("record-with-media video URL not extracted: %+v", vm2)
}
if vm2.HasSize {
t.Errorf("expected HasSize=false when no aspect ratio")
}
// Video without playlist — skip entirely.
pv3 := makePostView("alice.bsky.social", "did:plc:alice", "abc", "no playlist")
pv3.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedVideo_View: &appbsky.EmbedVideo_View{
Thumbnail: strPtr("https://cdn.bsky.app/thumb@jpg"),
},
}
if vm := extractVideoMeta(pv3, false); vm.URL != "" {
t.Errorf("video without playlist should produce empty meta, got %+v", vm)
}
}
-553
View File
@@ -1,553 +0,0 @@
package main
import (
"encoding/json"
"fmt"
appbsky "github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// schema.org structured-data types emitted on post and profile pages.
// Building these as Go structs (rather than inline JSON in templates) gives
// us correct JSON escaping via encoding/json and a single place to test the
// schema. Optional fields use omitempty so empty values don't reach Google's
// validator as null/empty strings.
const schemaOrgContext = "https://schema.org"
type interactionStat struct {
Type string `json:"@type"`
InteractionType string `json:"interactionType"`
UserInteractionCount int64 `json:"userInteractionCount"`
}
type personOrOrg struct {
Type string `json:"@type"`
Name string `json:"name,omitempty"`
AlternateName string `json:"alternateName,omitempty"`
Identifier string `json:"identifier,omitempty"`
URL string `json:"url,omitempty"`
Description string `json:"description,omitempty"`
Image string `json:"image,omitempty"`
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
AgentInteractionStat []interactionStat `json:"agentInteractionStatistic,omitempty"`
ReviewedBy []*verifier `json:"reviewedBy,omitempty"`
}
// verifier is the schema.org Person shape emitted under Person.reviewedBy.
// Narrower than personOrOrg: just enough to identify the verifying entity.
type verifier struct {
Type string `json:"@type"`
Name string `json:"name,omitempty"`
AlternateName string `json:"alternateName,omitempty"`
Identifier string `json:"identifier,omitempty"`
URL string `json:"url,omitempty"`
}
type sharedContent struct {
Type string `json:"@type"`
URL string `json:"url"`
}
type discussionForumPosting struct {
Context string `json:"@context,omitempty"`
Type string `json:"@type"`
URL string `json:"url,omitempty"`
Identifier string `json:"identifier,omitempty"`
Author *personOrOrg `json:"author,omitempty"`
Text string `json:"text,omitempty"`
Image []string `json:"image,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
CommentCount *int64 `json:"commentCount,omitempty"`
Comment []comment `json:"comment,omitempty"`
IsBasedOn string `json:"isBasedOn,omitempty"`
SharedContent *sharedContent `json:"sharedContent,omitempty"`
}
// comment is the schema.org Comment shape used in
// DiscussionForumPosting.comment[]. The comment property does not accept
// DiscussionForumPosting, so replies map to Comment.
type comment struct {
Type string `json:"@type"`
URL string `json:"url,omitempty"`
Identifier string `json:"identifier,omitempty"`
Author *personOrOrg `json:"author,omitempty"`
Text string `json:"text,omitempty"`
Image []string `json:"image,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
}
type webPage struct {
Context string `json:"@context"`
Type string `json:"@type"`
URL string `json:"url,omitempty"`
MainEntity discussionForumPosting `json:"mainEntity"`
}
type profilePage struct {
Context string `json:"@context"`
Type string `json:"@type"`
DateCreated string `json:"dateCreated,omitempty"`
MainEntity *personOrOrg `json:"mainEntity"`
HasPart []discussionForumPosting `json:"hasPart,omitempty"`
}
// maxComments caps DiscussionForumPosting.comment[] to keep SSR HTML small.
const maxComments = 10
// maxRecentPosts caps ProfilePage.hasPart[].
const maxRecentPosts = 10
// maxReviewedBy caps Person.reviewedBy entries.
const maxReviewedBy = 10
// authorFeedFetchLimit oversamples getAuthorFeed because posts_no_replies
// still returns reposts; we drop those client-side. 3x is a safe margin.
const authorFeedFetchLimit = 3 * maxRecentPosts
// bskyPostURL returns the canonical handle-form post URL, or "" if handle
// or rkey is unusable.
func bskyPostURL(handle, rkey string) string {
if handle == "" || handle == "handle.invalid" || rkey == "" {
return ""
}
return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, rkey)
}
// bskyPostURLFromATURI is bskyPostURL for callers holding an at-uri.
func bskyPostURLFromATURI(handle, atURI string) string {
parsed, err := syntax.ParseATURI(atURI)
if err != nil {
return ""
}
return bskyPostURL(handle, parsed.RecordKey().String())
}
// bskyProfileURL returns the canonical handle-form profile URL, or "" if
// the handle is unusable.
func bskyProfileURL(handle string) string {
if handle == "" || handle == "handle.invalid" {
return ""
}
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
}
// extractPostMedia returns thumbnail URLs for the post's image or video
// embed, byte-identical to what we put in og:image. Callers derive
// thumbnailUrl from urls[0].
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
if pv == nil || pv.Embed == nil || embedHidden {
return nil
}
if pv.Embed.EmbedImages_View != nil {
return imageThumbs(pv.Embed.EmbedImages_View.Images)
}
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
}
if pv.Embed.EmbedRecordWithMedia_View != nil && pv.Embed.EmbedRecordWithMedia_View.Media != nil {
media := pv.Embed.EmbedRecordWithMedia_View.Media
if media.EmbedImages_View != nil {
return imageThumbs(media.EmbedImages_View.Images)
}
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
return []string{*media.EmbedVideo_View.Thumbnail}
}
}
return nil
}
// imageThumbs returns the thumb URLs, or nil if empty.
func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
if len(images) == 0 {
return nil
}
urls := make([]string, 0, len(images))
for _, img := range images {
urls = append(urls, img.Thumb)
}
return urls
}
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
// if the embed is blocked / not-found / detached / a non-post record.
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
if pv == nil || pv.Embed == nil {
return ""
}
var rec *appbsky.EmbedRecord_View
if pv.Embed.EmbedRecord_View != nil {
rec = pv.Embed.EmbedRecord_View
} else if pv.Embed.EmbedRecordWithMedia_View != nil {
rec = pv.Embed.EmbedRecordWithMedia_View.Record
}
if rec == nil || rec.Record == nil {
return ""
}
vr := rec.Record.EmbedRecord_ViewRecord
if vr == nil || vr.Author == nil {
return ""
}
return bskyPostURLFromATURI(vr.Author.Handle, vr.Uri)
}
// extractSharedContentURL returns the URL of an external link embed (also
// from the media slot of a record-with-media embed).
func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
if pv == nil || pv.Embed == nil {
return ""
}
if pv.Embed.EmbedExternal_View != nil && pv.Embed.EmbedExternal_View.External != nil {
return pv.Embed.EmbedExternal_View.External.Uri
}
if pv.Embed.EmbedRecordWithMedia_View != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View.External != nil {
return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View.External.Uri
}
return ""
}
// buildAuthor constructs a Person. Organization classification for
// custom-domain accounts is a future enhancement.
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
if author == nil {
return nil
}
p := &personOrOrg{
Type: "Person",
}
if author.Did != "" {
p.Identifier = author.Did
}
if author.DisplayName != nil && *author.DisplayName != "" {
p.Name = *author.DisplayName
if author.Handle != "" {
p.AlternateName = "@" + author.Handle
}
} else if author.Handle != "" {
p.Name = "@" + author.Handle
}
if url := bskyProfileURL(author.Handle); url != "" {
p.URL = url
}
return p
}
// buildReviewedBy maps a profile's verifications to Person entries for
// Person.reviewedBy. Skips !IsValid and missing Issuer; caps at
// maxReviewedBy. URL falls back to DID-form when the verifier's handle is
// missing or "handle.invalid".
func buildReviewedBy(state *appbsky.ActorDefs_VerificationState) []*verifier {
if state == nil || len(state.Verifications) == 0 {
return nil
}
var out []*verifier
for _, v := range state.Verifications {
if v == nil || !v.IsValid || v.Issuer == "" {
continue
}
if len(out) >= maxReviewedBy {
break
}
entry := &verifier{
Type: "Person",
Identifier: v.Issuer,
}
var handle string
if v.IssuerHandle != nil {
handle = *v.IssuerHandle
}
if v.IssuerDisplayName != nil && *v.IssuerDisplayName != "" {
entry.Name = *v.IssuerDisplayName
if handle != "" && handle != "handle.invalid" {
entry.AlternateName = "@" + handle
}
} else if handle != "" && handle != "handle.invalid" {
entry.Name = "@" + handle
}
if url := bskyProfileURL(handle); url != "" {
entry.URL = url
} else {
entry.URL = "https://bsky.app/profile/" + v.Issuer
}
out = append(out, entry)
}
return out
}
// postHasHideLabel reports whether the post-view or self-labels include
// any non-negated label in labelSet. Self-labels have no negation.
func postHasHideLabel(pv *appbsky.FeedDefs_PostView, labelSet map[string]bool) bool {
if pv == nil {
return false
}
for _, label := range pv.Labels {
isNeg := label.Neg != nil && *label.Neg
if labelSet[label.Val] && !isNeg {
return true
}
}
if pv.Record != nil {
if rec, ok := pv.Record.Val.(*appbsky.FeedPost); ok {
if rec.Labels != nil && rec.Labels.LabelDefs_SelfLabels != nil {
for _, label := range rec.Labels.LabelDefs_SelfLabels.Values {
if labelSet[label.Val] {
return true
}
}
}
}
}
return false
}
// postEmbedHidden reports whether any post-view label or self-label asks
// embeds to be omitted. WebPost uses this so og:image and JSON-LD media
// suppression stay in sync.
func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) bool {
return postHasHideLabel(pv, hideLabels)
}
// postRecordText returns the post's expanded text, or "" if the record is
// missing or malformed.
func postRecordText(pv *appbsky.FeedDefs_PostView) string {
if pv == nil || pv.Record == nil {
return ""
}
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
if !ok {
return ""
}
return ExpandPostText(rec)
}
// buildPostStats returns the like / comment / share interaction triple.
// commentCount is emitted separately on DiscussionForumPosting.
func buildPostStats(pv *appbsky.FeedDefs_PostView) []interactionStat {
if pv == nil {
return nil
}
deref := func(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
return []interactionStat{
{Type: "InteractionCounter", InteractionType: "https://schema.org/LikeAction", UserInteractionCount: deref(pv.LikeCount)},
{Type: "InteractionCounter", InteractionType: "https://schema.org/CommentAction", UserInteractionCount: deref(pv.ReplyCount)},
{Type: "InteractionCounter", InteractionType: "https://schema.org/ShareAction", UserInteractionCount: deref(pv.RepostCount) + deref(pv.QuoteCount)},
}
}
// buildPostNode constructs a DiscussionForumPosting in nested form (no
// envelope, no @context). Used for top-level posts and for entries in
// hasPart / comment arrays. Returns the zero value if pv or pv.Author is
// nil; callers should treat that as "skip". Replies whose own labels match
// hideLabels or hideReplyLabels are dropped from comment[].
func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, hideLabels, hideReplyLabels map[string]bool) discussionForumPosting {
if pv == nil || pv.Author == nil {
return discussionForumPosting{}
}
embedHidden := postEmbedHidden(pv, hideLabels)
images := extractPostMedia(pv, embedHidden)
var thumb string
if len(images) > 0 {
thumb = images[0]
}
node := discussionForumPosting{
Type: "DiscussionForumPosting",
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
Identifier: pv.Uri,
Author: buildAuthor(pv.Author),
Text: postRecordText(pv),
Image: images,
ThumbnailURL: thumb,
DatePublished: pv.IndexedAt,
InteractionStat: buildPostStats(pv),
}
// Surface verifications on the post author only; replies do not.
if node.Author != nil {
if rb := buildReviewedBy(pv.Author.Verification); len(rb) > 0 {
node.Author.ReviewedBy = rb
}
}
if pv.ReplyCount != nil {
node.CommentCount = pv.ReplyCount
} else {
zero := int64(0)
node.CommentCount = &zero
}
if !embedHidden {
if quoted := extractQuotedPostURL(pv); quoted != "" {
node.IsBasedOn = quoted
}
if shared := extractSharedContentURL(pv); shared != "" {
node.SharedContent = &sharedContent{Type: "WebPage", URL: shared}
}
}
for _, r := range replies {
if r == nil || r.FeedDefs_ThreadViewPost == nil || r.FeedDefs_ThreadViewPost.Post == nil {
continue
}
if len(node.Comment) >= maxComments {
break
}
replyPV := r.FeedDefs_ThreadViewPost.Post
// Drop labeled replies entirely so abusive/spam text isn't surfaced
// into the parent post's structured data.
if postHasHideLabel(replyPV, hideReplyLabels) || postHasHideLabel(replyPV, hideLabels) {
continue
}
reply := buildReplyNode(replyPV, hideLabels)
if reply.Type == "" {
continue
}
node.Comment = append(node.Comment, reply)
}
return node
}
// buildReplyNode builds a schema.org Comment for a reply. Returns the
// zero value if pv or pv.Author is nil.
func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) comment {
if pv == nil || pv.Author == nil {
return comment{}
}
embedHidden := postEmbedHidden(pv, hideLabels)
images := extractPostMedia(pv, embedHidden)
var thumb string
if len(images) > 0 {
thumb = images[0]
}
return comment{
Type: "Comment",
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
Identifier: pv.Uri,
Author: buildAuthor(pv.Author),
Text: postRecordText(pv),
Image: images,
ThumbnailURL: thumb,
DatePublished: pv.IndexedAt,
}
}
// buildPostJSONLD marshals the WebPage envelope wrapping a
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
// (as a fallback) mainEntity.url so they always agree.
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
if pv == nil || pv.Author == nil {
return "", fmt.Errorf("nil post view or author")
}
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
// mainEntity.url is empty when the author handle is unusable; fall back
// to canonicalURL so it agrees with envelope.url.
if node.URL == "" {
node.URL = canonicalURL
}
envelope := webPage{
Context: schemaOrgContext,
Type: "WebPage",
URL: canonicalURL,
MainEntity: node,
}
b, err := json.Marshal(envelope)
if err != nil {
return "", err
}
return string(b), nil
}
// buildProfileJSONLD marshals a ProfilePage (with hasPart recent posts).
// Recent posts whose own labels match hideLabels or hideReplyLabels are
// dropped from hasPart, mirroring the gating applied to comment[].
func buildProfileJSONLD(pv *appbsky.ActorDefs_ProfileViewDetailed, recentPosts []*appbsky.FeedDefs_PostView, hideLabels, hideReplyLabels map[string]bool) (string, error) {
if pv == nil {
return "", fmt.Errorf("nil profile view")
}
person := &personOrOrg{
Type: "Person",
Identifier: pv.Did,
}
if pv.DisplayName != nil && *pv.DisplayName != "" {
person.Name = *pv.DisplayName
person.AlternateName = "@" + pv.Handle
} else {
person.Name = "@" + pv.Handle
}
if pv.Description != nil {
person.Description = *pv.Description
}
if pv.Avatar != nil {
person.Image = *pv.Avatar
}
deref := func(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
person.InteractionStat = []interactionStat{
{Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowersCount)},
}
person.AgentInteractionStat = []interactionStat{
{Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowsCount)},
{Type: "InteractionCounter", InteractionType: "https://schema.org/WriteAction", UserInteractionCount: deref(pv.PostsCount)},
}
if rb := buildReviewedBy(pv.Verification); len(rb) > 0 {
person.ReviewedBy = rb
}
page := profilePage{
Context: schemaOrgContext,
Type: "ProfilePage",
MainEntity: person,
}
if pv.CreatedAt != nil {
page.DateCreated = *pv.CreatedAt
}
for _, rp := range recentPosts {
if rp == nil {
continue
}
if len(page.HasPart) >= maxRecentPosts {
break
}
// Drop labeled posts entirely so flagged content isn't surfaced
// into the profile's structured data.
if postHasHideLabel(rp, hideReplyLabels) || postHasHideLabel(rp, hideLabels) {
continue
}
// Recent posts go in nested form. No replies are passed, so the
// reply-label set is irrelevant; pass nil.
node := buildPostNode(rp, nil, hideLabels, nil)
if node.Type == "" {
continue
}
page.HasPart = append(page.HasPart, node)
}
b, err := json.Marshal(page)
if err != nil {
return "", err
}
return string(b), nil
}
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
package main
import (
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
// Helpers for inspecting profile / post labels.
// profileRequiresAuth reports whether the profile has self-applied the
// !no-unauthenticated label. SSR responses for these profiles omit post
// content beyond minimal identity.
func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
if pv == nil {
return false
}
for _, label := range pv.Labels {
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
return true
}
}
return false
}
-79
View File
@@ -1,79 +0,0 @@
package main
import (
"testing"
comatprototypes "github.com/bluesky-social/indigo/api/atproto"
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
func TestProfileRequiresAuth(t *testing.T) {
negTrue := true
tests := []struct {
name string
pv *appbsky.ActorDefs_ProfileViewDetailed
want bool
}{
{
name: "nil profile",
pv: nil,
want: false,
},
{
name: "no labels",
pv: &appbsky.ActorDefs_ProfileViewDetailed{Did: "did:plc:alice"},
want: false,
},
{
name: "self-applied !no-unauthenticated",
pv: &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice",
Labels: []*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "!no-unauthenticated"},
},
},
want: true,
},
{
name: "label from a different src does not gate",
pv: &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice",
Labels: []*comatprototypes.LabelDefs_Label{
{Src: "did:plc:labeler", Val: "!no-unauthenticated"},
},
},
want: false,
},
{
name: "different label value does not gate",
pv: &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice",
Labels: []*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "spam"},
},
},
want: false,
},
{
// Negation isn't honored — matches prior inline behavior in
// WebPost / WebProfile / WebProfileRSS.
name: "negated label still triggers (matches prior behavior)",
pv: &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice",
Labels: []*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 := profileRequiresAuth(tt.pv); got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
-6
View File
@@ -46,12 +46,6 @@ func run(args []string) {
Required: false,
EnvVars: []string{"OGCARD_HOST"},
},
&cli.StringFlag{
Name: "chat-host",
Usage: "scheme, hostname, and port of chat appview",
Required: false,
EnvVars: []string{"CHAT_HOST"},
},
&cli.StringFlag{
Name: "http-address",
Usage: "Specify the local IP/port to bind to",
-211
View File
@@ -1,211 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"regexp"
"strings"
"testing"
"github.com/bluesky-social/social-app/bskyweb"
"github.com/flosch/pongo2/v6"
)
// renderTemplate executes a template using the live renderer and returns
// the output. base.html includes templates/scripts.html, which is generated
// by `yarn build-web`; skip the test if it's missing.
func renderTemplate(t *testing.T, name string, ctx pongo2.Context) string {
t.Helper()
if _, err := bskyweb.TemplateFS.ReadFile("templates/scripts.html"); err != nil {
t.Skip("templates/scripts.html not present (run yarn build-web first)")
}
r := NewRenderer("templates/", &bskyweb.TemplateFS, false)
tmpl, err := r.TemplateSet.FromCache(name)
if err != nil {
t.Fatalf("template load %q: %v", name, err)
}
var buf bytes.Buffer
if err := tmpl.ExecuteWriter(ctx, &buf); err != nil {
t.Fatalf("template execute %q: %v", name, err)
}
return buf.String()
}
var jsonLDRe = regexp.MustCompile(`(?s)<script type="application/ld\+json">(.*?)</script>`)
// extractJSONLD pulls out the body of the application/ld+json script tag.
func extractJSONLD(t *testing.T, html string) string {
t.Helper()
m := jsonLDRe.FindStringSubmatch(html)
if m == nil {
t.Fatalf("no application/ld+json script tag in rendered HTML:\n%s", html)
}
return strings.TrimSpace(m[1])
}
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)
if err != nil {
t.Fatal(err)
}
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
})
body := extractJSONLD(t, html)
var parsed map[string]any
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
}
if parsed["@type"] != "WebPage" {
t.Errorf("expected WebPage envelope, got %v", parsed["@type"])
}
// Canonical link should be the handle-form URL.
if !strings.Contains(html, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
t.Errorf("canonical link missing or wrong:\n%s", html)
}
}
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)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
"imgThumbUrls": []string{thumb1, thumb2},
})
// og:image and JSON-LD image[] must be byte-identical.
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
t.Errorf("og:image[0] not found in rendered HTML")
}
body := extractJSONLD(t, html)
var parsed map[string]any
_ = json.Unmarshal([]byte(body), &parsed)
main := parsed["mainEntity"].(map[string]any)
imgs := main["image"].([]any)
if imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
t.Errorf("JSON-LD image strings drifted from og:image; image[0]=%v thumbnailUrl=%v",
imgs[0], main["thumbnailUrl"])
}
}
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
"postJSONLD": ld,
})
if !strings.Contains(html, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
t.Errorf("expected canonicalize_url filter to strip query; got:\n%s", html)
}
}
func TestRenderProfile_EmitsJSONLD(t *testing.T) {
pv := newProfileViewDetailed()
ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
html := renderTemplate(t, "profile.html", pongo2.Context{
"profileView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social",
"profileJSONLD": ld,
})
body := extractJSONLD(t, html)
var parsed map[string]any
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
}
if parsed["@type"] != "ProfilePage" {
t.Errorf("expected ProfilePage, got %v", parsed["@type"])
}
}
// Regression: auth-required profiles must still emit ProfilePage JSON-LD
// (without hasPart). Previously regressed when WebProfile short-circuited
// before buildProfileJSONLD.
func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
pv := newProfileViewDetailed()
ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
html := renderTemplate(t, "profile.html", pongo2.Context{
"profileView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social",
"requiresAuth": true,
"profileJSONLD": ld,
})
body := extractJSONLD(t, html)
var parsed map[string]any
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
}
if parsed["@type"] != "ProfilePage" {
t.Errorf("expected ProfilePage, got %v", parsed["@type"])
}
if _, present := parsed["hasPart"]; present {
t.Errorf("auth-required profile should not have hasPart")
}
}
// 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)
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/did:plc:alice/post/abc123",
"canonicalURL": canonical,
"postJSONLD": ld,
})
if !strings.Contains(html, `<meta property="og:url" content="`+canonical+`">`) {
t.Errorf("og:url should equal canonical URL when set; got:\n%s", html)
}
if !strings.Contains(html, `<link rel="canonical" href="`+canonical+`" />`) {
t.Errorf("canonical link missing or wrong:\n%s", html)
}
// DID-form request URI must not leak into og:url.
if strings.Contains(html, `<meta property="og:url" content="https://bsky.app/profile/did:plc:alice/post/abc123">`) {
t.Errorf("og:url should not echo DID-form request URI when canonical is set")
}
}
// og:video must emit even when there is no thumbnail. Previously the
// {% if videoUrl %} block was nested inside {% if imgThumbUrls %}, so a
// 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)
videoURL := "https://video.bsky.app/v.m3u8"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
"videoUrl": videoURL,
"videoType": "application/x-mpegURL",
})
if !strings.Contains(html, `<meta property="og:video" content="`+videoURL+`">`) {
t.Errorf("og:video should emit even without imgThumbUrls; got:\n%s", html)
}
if !strings.Contains(html, `<meta property="og:video:type" content="application/x-mpegURL">`) {
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
}
}
+8 -4
View File
@@ -58,8 +58,10 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %s", handle))
}
if profileRequiresAuth(pv) {
return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", handle))
for _, label := range pv.Labels {
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", handle))
}
}
return c.Redirect(http.StatusFound, fmt.Sprintf("/profile/%s/rss", pv.Did))
}
@@ -74,8 +76,10 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %s", did))
}
if profileRequiresAuth(pv) {
return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", did))
for _, label := range pv.Labels {
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", did))
}
}
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, did.String(), "", "posts_no_replies", false, 30)

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