Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8eac442b1 | |||
| 57ae26fcaa | |||
| 0a5a994170 | |||
| f99837040e | |||
| 4367b9d047 | |||
| 84d1785e12 | |||
| cdad723ea1 | |||
| 1b4c72f516 | |||
| b6650d8d5c | |||
| 21679b66dd | |||
| a046d8d96a | |||
| 00497964c1 | |||
| f4f66af31c | |||
| 3c06ecfa1a | |||
| 711334b34e | |||
| c88218ab87 | |||
| 0a7c087078 | |||
| 73dae02df3 | |||
| 75490c3cdf | |||
| b87a51b485 | |||
| 56496520d6 | |||
| d08ab487d8 | |||
| a2c8b47b87 | |||
| 8e5ac6790f | |||
| 53fa6b2493 | |||
| 61e10f7e0b | |||
| 59765138ce | |||
| dfa82904ec | |||
| bdbf31a254 | |||
| 2b31047b0d | |||
| fd9f988b90 | |||
| cbf8e95bdb | |||
| 771fd5ddf6 | |||
| 26e48e3876 | |||
| 2abfba9771 | |||
| 0dfd7cfa12 | |||
| 237232f96c | |||
| db1c4dc6e3 | |||
| 751eda59fd | |||
| 62d1e88ce1 | |||
| ba24c7c357 | |||
| 0d38e2ab3f | |||
| bccca283b9 | |||
| 601dae0cda | |||
| 9e275d3898 | |||
| 855d785bf8 | |||
| bac06bdf23 | |||
| 3205fcd7b3 | |||
| d0ad7819bf | |||
| b0708dcefb | |||
| ce4efc511b | |||
| e52adeac12 | |||
| a5e29442b5 | |||
| 806f31dc93 | |||
| 98f14bab39 | |||
| 9f69e7f8ff | |||
| 28106639f4 | |||
| 5123680a11 | |||
| bcdf707a8a | |||
| 3e46ca391f | |||
| 672cebef62 | |||
| af04e8b496 | |||
| 5e27d8c85d | |||
| 60917d1125 | |||
| 6432c925c2 | |||
| 7f5dd40069 | |||
| 13f494502d | |||
| e8f61ac3da | |||
| addfdd192a | |||
| 3d8875374e | |||
| 721a08ffe1 | |||
| 4e4e1e1190 | |||
| 731f32abda | |||
| a892b3dcc9 | |||
| 7ac288de12 | |||
| 5bad7d3ceb | |||
| 8720ef72ce | |||
| afa6a98349 | |||
| 1113686e30 | |||
| 308b2e05f1 | |||
| 3f4cd1c683 | |||
| a5a6293467 | |||
| dc62ce2fb0 | |||
| 68431182ef | |||
| 4e2949c93a | |||
| 1790fddc78 | |||
| 4eb692cf87 | |||
| 3552c26449 | |||
| 98cdeef5b2 | |||
| f5dcce1718 | |||
| 709589fa36 | |||
| ebed23657f | |||
| c1f42ef877 | |||
| 5281c48de3 | |||
| bf7633b794 | |||
| d404363093 | |||
| c4861cd073 | |||
| 537a1f7120 | |||
| bf83f165ee | |||
| 1b14ea2413 | |||
| 5db37729bb | |||
| 6159cd7777 | |||
| 0d14513939 | |||
| 66d37f55b4 | |||
| 96ae32b78e | |||
| 4bdedc55be | |||
| f2e7028709 | |||
| 2a546bfd52 | |||
| c21c031e74 | |||
| 42d993a78f | |||
| 7c5a9ab40d | |||
| 2f4049e6e0 | |||
| 53df5e62bb | |||
| 4c73b171d6 | |||
| 6f0912346c | |||
| 6fd81fe5dc | |||
| e1e5f28e98 | |||
| f653faab94 | |||
| 7da3f385a8 | |||
| 0858010a5d | |||
| 901b1a3a9e | |||
| c0f7ffa728 | |||
| 1b546c54db | |||
| 7fe2f892f6 | |||
| b73f9110e5 | |||
| c4ecb007f3 | |||
| 3d4ba946b3 | |||
| a2417e043e | |||
| 21446b43af | |||
| 4908816253 | |||
| c1817d3425 | |||
| 9cad883ab8 | |||
| 46a4f6cbee | |||
| 5088f00c35 | |||
| 70a1a665ed | |||
| 2f80d983d3 | |||
| 20f1c60782 | |||
| 34ae819aab | |||
| 7b6a81ac23 | |||
| 8cea36f3c9 | |||
| 09253457f9 | |||
| 12ab1dd2fd | |||
| b4054bf2a7 | |||
| 2cee865f4a | |||
| 683ae7610f | |||
| 366cf7e040 | |||
| 8e8dc7561f | |||
| af6041d7bc | |||
| b795757a86 | |||
| bc148ede43 | |||
| f9f11c802b | |||
| 632ce5cc37 | |||
| 52342f1cda | |||
| 79872b8be5 | |||
| 5b622079b6 | |||
| 0dc062294f | |||
| e40c9bcf98 | |||
| b1ce519361 | |||
| ff731849b0 | |||
| b7c2c2f183 | |||
| e58a09e0ca |
@@ -0,0 +1 @@
|
||||
alt-blur@3076a2b5a593b0d7b37198b5aebfe3a222afd52d
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpm typecheck *)",
|
||||
"Bash(pnpm lint *)",
|
||||
"Bash(pnpm test *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(yarn typecheck *)",
|
||||
"Bash(yarn lint *)",
|
||||
"Bash(yarn test *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
version: 2
|
||||
# Dependabot auto-update config.
|
||||
#
|
||||
# Cooldown (7 days) is the point of this config: it delays version-update
|
||||
# PRs until a newly-published version has aged. Supply-chain attacks like
|
||||
# the tanstack Shai-Hulud compromise (2026-05-11) live minutes-to-hours
|
||||
# before the registry yanks them; a 7-day cooldown keeps poisoned
|
||||
# versions out of our lockfiles.
|
||||
#
|
||||
# Security updates bypass cooldown and continue to flow immediately. See:
|
||||
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown
|
||||
#
|
||||
# Auto-merge is deliberately NOT enabled. Every dependabot PR gets human
|
||||
# review.
|
||||
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
production:
|
||||
dependency-type: production
|
||||
update-types: [minor, patch]
|
||||
development:
|
||||
dependency-type: development
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
update-types: [minor, patch]
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
@@ -28,39 +28,38 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⛏️ Setup EAS local builds
|
||||
run: yarn global add eas-cli-local-build-plugin
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build 2>&1 | tee i18n.log
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
@@ -88,8 +87,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 }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p android
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p android
|
||||
--profile $PROFILE
|
||||
--local --output build.aab --non-interactive
|
||||
|
||||
@@ -106,7 +105,7 @@ jobs:
|
||||
- name: 🚀 Upload Production Artifact
|
||||
id: upload-artifact-production
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
@@ -116,7 +115,7 @@ jobs:
|
||||
- name: 🚀 Upload Testflight Artifact
|
||||
id: upload-artifact-testflight
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
@@ -129,27 +128,23 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{
|
||||
"text": "Android production build for Google Play Store submission is ready!\n```Artifact: ${{ steps.upload-artifact-production.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
{"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 }}```"}
|
||||
|
||||
- name: 🔔 Notify Slack of Testflight Build
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
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 }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact-testflight.outputs.artifact-url }}"}
|
||||
|
||||
- name: 🧹 Clear Metro cache
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
@@ -162,15 +157,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 }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p android
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm 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@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
@@ -179,19 +174,17 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Production APK Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{
|
||||
"text": "Android production build for GitHub/Obtanium is ready!\n```Artifact: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
{"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 }}```"}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
|
||||
@@ -28,31 +28,30 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
with:
|
||||
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
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
@@ -64,16 +63,16 @@ jobs:
|
||||
version: 1.16.2
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
# 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') }}
|
||||
# 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') }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build 2>&1 | tee i18n.log
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
@@ -100,8 +99,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 }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p ios
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p ios
|
||||
--profile $PROFILE
|
||||
--local --output build.tar.gz --non-interactive
|
||||
|
||||
@@ -141,14 +140,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
|
||||
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 }}
|
||||
yarn sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
|
||||
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
|
||||
|
||||
- name: 📚 Get version from package.json
|
||||
id: get-build-info
|
||||
@@ -156,19 +155,17 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{
|
||||
"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_IOS_BUILD_NUMBER }}```"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
{"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 }}```"}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -56,11 +56,13 @@ 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: .nvmrc
|
||||
cache: yarn
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
@@ -69,36 +71,29 @@ 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: yarn intl:build 2>&1 | tee i18n.log
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: Prettier check
|
||||
run: pnpm prettier --check .
|
||||
|
||||
- name: Type check
|
||||
run: yarn typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
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
|
||||
@@ -130,11 +125,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 }}
|
||||
yarn export
|
||||
pnpm export
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: yarn use-build-number bash scripts/bundleUpdate.sh
|
||||
run: pnpm use-build-number bash scripts/bundleUpdate.sh
|
||||
env:
|
||||
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
|
||||
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
||||
@@ -142,7 +137,7 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
@@ -172,28 +167,27 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
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
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
@@ -205,16 +199,16 @@ jobs:
|
||||
version: 1.16.2
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
# 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') }}
|
||||
# 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') }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build
|
||||
run: pnpm intl:build
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: Env
|
||||
@@ -236,8 +230,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 }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p ios
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p ios
|
||||
--profile testflight
|
||||
--local --output build.tar.gz --non-interactive
|
||||
|
||||
@@ -277,18 +271,18 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
|
||||
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 }}
|
||||
yarn sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
|
||||
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
if: ${{ inputs.channel == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
@@ -320,36 +314,35 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⛏️ Setup EAS local builds
|
||||
run: yarn global add eas-cli-local-build-plugin
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build
|
||||
run: pnpm intl:build
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: Env
|
||||
@@ -372,8 +365,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 }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p android
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p android
|
||||
--profile testflight-android
|
||||
--local --output build.apk --non-interactive
|
||||
|
||||
@@ -385,7 +378,7 @@ jobs:
|
||||
|
||||
- name: 🚀 Upload Artifact
|
||||
id: upload-artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
@@ -393,19 +386,17 @@ jobs:
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
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 }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -6,34 +6,43 @@ 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@v4
|
||||
- name: Verify Node version pins match .nvmrc
|
||||
uses: actions/checkout@v5
|
||||
- name: Verify Node version pins match package.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected=$(tr -d '[:space:]' < .nvmrc)
|
||||
expected=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g, '')")
|
||||
rc=0
|
||||
check() {
|
||||
if [ "$2" != "$expected" ]; then
|
||||
echo "::error file=$1::Node version mismatch: $1 pins '$2' but .nvmrc is '$expected'"
|
||||
echo "::error file=$1::Node version mismatch: $1 pins '$2' but package.json is '$expected'"
|
||||
rc=1
|
||||
fi
|
||||
}
|
||||
# FROM node:X.Y.Z — service runtime images
|
||||
# FROM node:X.Y.Z - service runtime images
|
||||
for f in Dockerfile.bskylink Dockerfile.bskyogcard; do
|
||||
v=$(grep -oE 'FROM node:[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
|
||||
check "$f" "$v"
|
||||
done
|
||||
# ENV NODE_VERSION=X.Y.Z — Go images that nvm-install Node for the JS build stage
|
||||
for f in Dockerfile Dockerfile.embedr; do
|
||||
# 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
|
||||
@@ -41,46 +50,78 @@ jobs:
|
||||
v=$(grep -oE '"node":[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' eas.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
|
||||
check "eas.json" "$v"
|
||||
exit $rc
|
||||
- uses: pnpm/action-setup@v6
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
- name: Yarn install
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: yarn --frozen-lockfile
|
||||
command: pnpm install --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: yarn intl:build
|
||||
- name: Type check
|
||||
run: yarn typecheck
|
||||
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"
|
||||
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@v4
|
||||
uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
- name: Yarn install
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: yarn --frozen-lockfile
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Check & compile i18n
|
||||
run: yarn intl:build
|
||||
run: pnpm intl:build
|
||||
- name: Run tests
|
||||
run: |
|
||||
NODE_ENV=test yarn test --forceExit
|
||||
NODE_ENV=test pnpm test --forceExit --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
# Aggregates the sharded test results into a single stable check name so branch
|
||||
# protection can require "Run tests" regardless of how many shards run.
|
||||
# The result is asserted in `run` (not `if`) so a malformed expression can
|
||||
# never silently skip the check and report a false pass.
|
||||
testing-summary:
|
||||
name: Run tests
|
||||
if: always()
|
||||
needs: [testing]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require testing to have succeeded
|
||||
env:
|
||||
RESULT: ${{ needs.testing.result }}
|
||||
run: |
|
||||
echo "testing result: $RESULT"
|
||||
test "$RESULT" = "success"
|
||||
|
||||
@@ -16,22 +16,23 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
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: .nvmrc
|
||||
cache: yarn
|
||||
- name: Yarn install
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: yarn --frozen-lockfile
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Extract language strings
|
||||
run: yarn intl:extract
|
||||
run: pnpm intl:extract
|
||||
- name: Create commit
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
core.setOutput('head-ref', pr.data.head.ref);
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
@@ -113,44 +113,40 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
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: .nvmrc
|
||||
cache: yarn
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Lint check
|
||||
run: yarn lint
|
||||
|
||||
- name: Lint lockfile
|
||||
run: yarn lockfile-lint
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build 2>&1 | tee i18n.log
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: yarn typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
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
|
||||
|
||||
@@ -175,17 +171,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 }}
|
||||
yarn export
|
||||
pnpm export
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy
|
||||
run: yarn use-build-number bash scripts/bundleUpdate.sh
|
||||
run: pnpm 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@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
@@ -202,7 +198,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
if: failure()
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
|
||||
@@ -24,15 +24,17 @@ jobs:
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Ensure tracking relevant branches and checkout base
|
||||
env:
|
||||
@@ -56,17 +58,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
|
||||
yarn install
|
||||
yarn intl:build
|
||||
pnpm install
|
||||
pnpm intl:build
|
||||
|
||||
- name: 🔦 Generate stats file for PR
|
||||
run: |
|
||||
yarn generate-webpack-stats-file
|
||||
pnpm generate-webpack-stats-file
|
||||
mv stats.json ../stats-new.json
|
||||
|
||||
- name: ⬇️ Get base stats from cache
|
||||
id: get-base-stats
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: stats-base.json
|
||||
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
|
||||
@@ -80,9 +82,9 @@ jobs:
|
||||
- name: 🔦 Generate stats file from base commit
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
run: |
|
||||
yarn install
|
||||
yarn intl:build
|
||||
yarn generate-webpack-stats-file
|
||||
pnpm install
|
||||
pnpm intl:build
|
||||
pnpm generate-webpack-stats-file
|
||||
mv stats.json stats-base.json
|
||||
|
||||
- name: % Get diff
|
||||
@@ -94,7 +96,7 @@ jobs:
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
with:
|
||||
header: bundle-diff
|
||||
message: |
|
||||
@@ -108,7 +110,7 @@ jobs:
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 100
|
||||
|
||||
@@ -116,11 +118,13 @@ 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: .nvmrc
|
||||
cache: yarn
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
@@ -129,7 +133,7 @@ jobs:
|
||||
profile: pull-request
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
@@ -147,7 +151,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Delete comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
|
||||
@@ -10,12 +10,12 @@ jobs:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
steps:
|
||||
- name: Checkout public repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||
|
||||
@@ -7,12 +7,12 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify-yarn-lock:
|
||||
name: No manual yarn.lock edits
|
||||
verify-pnpm-lock:
|
||||
name: No manual pnpm-lock.yaml edits
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out PR HEAD
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -21,28 +21,30 @@ 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: .nvmrc
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Reset yarn.lock to base
|
||||
- name: Reset pnpm-lock.yaml to base
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git show "origin/$BASE_REF:yarn.lock" > yarn.lock
|
||||
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
|
||||
|
||||
- name: Yarn install
|
||||
- name: pnpm install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
# Fine to skip scripts since we don't run any code
|
||||
command: yarn install --ignore-scripts
|
||||
command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
|
||||
- name: Verify yarn.lock
|
||||
- name: Verify pnpm-lock.yaml
|
||||
run: |
|
||||
git diff --quiet --exit-code || {
|
||||
echo '::error::`yarn.lock` does not match what Yarn would generate given the base `yarn.lock` and the head `package.json`.'
|
||||
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:: - If this is intentional, you can ignore this check.'
|
||||
echo '::error:: - If this is unintentional, apply the following diff:'
|
||||
git --no-pager diff
|
||||
@@ -1,4 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged --concurrent false
|
||||
pnpm exec lint-staged --concurrent false
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
!**/*.jsx
|
||||
!**/*.ts
|
||||
!**/*.tsx
|
||||
!**/*.mjs
|
||||
!**/*.cjs
|
||||
!*/
|
||||
|
||||
!**/*.css
|
||||
|
||||
!**/pnpm-workspace.yaml
|
||||
|
||||
# More specific ignores go below.
|
||||
.expo
|
||||
android
|
||||
|
||||
@@ -22,25 +22,25 @@ Prefer using the latest features available for each of these libraries (exact ve
|
||||
|
||||
```bash
|
||||
# Development
|
||||
yarn start # Start Expo dev server
|
||||
yarn web # Start web version
|
||||
yarn android # Run on Android
|
||||
yarn ios # Run on iOS
|
||||
pnpm start # Start Expo dev server
|
||||
pnpm web # Start web version
|
||||
pnpm android # Run on Android
|
||||
pnpm ios # Run on iOS
|
||||
|
||||
# Testing & Quality
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# Internationalization
|
||||
# DO NOT run these commands - extraction and compilation are handled by CI
|
||||
yarn intl:extract # Extract translation strings (nightly CI job)
|
||||
yarn intl:compile # Compile translations for runtime (nightly CI job)
|
||||
pnpm intl:extract # Extract translation strings (nightly CI job)
|
||||
pnpm intl:compile # Compile translations for runtime (nightly CI job)
|
||||
|
||||
# Build
|
||||
yarn build-web # Build web version
|
||||
yarn prebuild # Generate native projects
|
||||
pnpm build-web # Build web version
|
||||
pnpm prebuild # Generate native projects
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
@@ -206,7 +206,7 @@ function MyComponent() {
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -414,12 +414,16 @@ import {Text, H1, H2, P} from '#/components/Typography'
|
||||
|
||||
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
|
||||
<P>Paragraph text with default styling.</P>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
|
||||
|
||||
// For text with emoji, add the emoji prop
|
||||
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
|
||||
// will almost certainly contain emoji, so only omit it when the text is static and
|
||||
// does not contain an emoji
|
||||
<Text emoji>Hello! 👋</Text>
|
||||
```
|
||||
|
||||
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
|
||||
|
||||
### TextField
|
||||
|
||||
```tsx
|
||||
@@ -503,8 +507,8 @@ function MyComponent() {
|
||||
|
||||
```bash
|
||||
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
|
||||
yarn intl:extract # Extract new strings to locale files
|
||||
yarn intl:compile # Compile translations for runtime
|
||||
pnpm intl:extract # Extract new strings to locale files
|
||||
pnpm intl:compile # Compile translations for runtime
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
FROM golang:1.26-bookworm AS build-env
|
||||
#
|
||||
# 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
|
||||
|
||||
WORKDIR /usr/src/social-app
|
||||
WORKDIR /app
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
#
|
||||
# Node
|
||||
# pnpm config
|
||||
#
|
||||
ENV NODE_VERSION=24.15.0
|
||||
ENV NVM_DIR=/usr/share/nvm
|
||||
|
||||
#
|
||||
# Go
|
||||
#
|
||||
ENV GODEBUG="netdns=go"
|
||||
ENV GOOS="linux"
|
||||
ENV GOARCH="amd64"
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GOEXPERIMENT="loopvar"
|
||||
ENV CI=1
|
||||
# use the pnpm version specified in package.json
|
||||
ENV pnpm_config_pm_on_fail=download
|
||||
|
||||
# The latest git hash of the preview branch on render.com
|
||||
# https://render.com/docs/docker-secrets#environment-variables-in-docker-builds
|
||||
@@ -42,41 +40,48 @@ 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 . .
|
||||
|
||||
#
|
||||
# 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" && \
|
||||
RUN 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 && \
|
||||
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
|
||||
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
|
||||
|
||||
# DEBUG
|
||||
RUN find ./bskyweb/static && find ./web-build/static
|
||||
RUN find ./bskyweb/static
|
||||
|
||||
#
|
||||
# Generate the bskyweb Go binary.
|
||||
#
|
||||
RUN cd bskyweb/ && \
|
||||
go mod download && \
|
||||
go mod verify
|
||||
@@ -89,6 +94,9 @@ RUN cd bskyweb/ && \
|
||||
-o /bskyweb \
|
||||
./cmd/bskyweb
|
||||
|
||||
#
|
||||
# Stage 3: runtime image.
|
||||
#
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV GODEBUG=netdns=go
|
||||
@@ -102,7 +110,7 @@ RUN apt-get update && apt-get install --yes \
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
|
||||
WORKDIR /bskyweb
|
||||
COPY --from=build-env /bskyweb /usr/bin/bskyweb
|
||||
COPY --from=go-build /bskyweb /usr/bin/bskyweb
|
||||
|
||||
CMD ["/usr/bin/bskyweb"]
|
||||
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
FROM node:24.15.0-alpine3.22 AS build
|
||||
|
||||
# Tells pnpm to run non-interactively (needed for install/script steps)
|
||||
ENV CI=true
|
||||
|
||||
# Move files into the image and install
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./bskyogcard/package.json ./
|
||||
COPY ./bskyogcard/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
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 ./
|
||||
|
||||
# build then prune dev deps
|
||||
RUN yarn install-fonts && yarn build
|
||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||
RUN pnpm install-fonts && pnpm build
|
||||
RUN pnpm install --prod --ignore-scripts --prefer-offline
|
||||
|
||||
# Uses assets from build stage to reduce build size
|
||||
FROM node:24.15.0-alpine3.22
|
||||
|
||||
@@ -11,10 +11,14 @@ ENV NVM_DIR=/usr/share/nvm
|
||||
# Go
|
||||
ENV GODEBUG="netdns=go"
|
||||
ENV GOOS="linux"
|
||||
ENV GOARCH="amd64"
|
||||
ARG TARGETARCH
|
||||
ENV GOARCH=${TARGETARCH:-amd64}
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GOEXPERIMENT="loopvar"
|
||||
|
||||
# CI environment for pnpm
|
||||
ENV CI=true
|
||||
|
||||
COPY . .
|
||||
|
||||
#
|
||||
@@ -29,11 +33,11 @@ RUN mkdir --parents $NVM_DIR && \
|
||||
RUN \. "$NVM_DIR/nvm.sh" && \
|
||||
nvm install $NODE_VERSION && \
|
||||
nvm use $NODE_VERSION && \
|
||||
npm install --global yarn && \
|
||||
yarn && \
|
||||
cd bskyembed && yarn install --frozen-lockfile && cd .. && \
|
||||
yarn intl:build && \
|
||||
yarn build-embed
|
||||
npm install --global pnpm@11.5.0 && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
||||
pnpm intl:build && \
|
||||
pnpm build-embed
|
||||
|
||||
# DEBUG
|
||||
RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bskyembed/dist
|
||||
|
||||
@@ -10,33 +10,33 @@ help: ## Print info about all commands
|
||||
|
||||
.PHONY: build-web
|
||||
build-web: ## Compile web bundle, copy to bskyweb directory
|
||||
yarn intl:build
|
||||
yarn build-web
|
||||
pnpm intl:build
|
||||
pnpm build-web
|
||||
|
||||
.PHONY: build-web-embed
|
||||
build-web-embed: ## Compile web embed bundle, copy to bskyweb/embedr* directories
|
||||
yarn intl:build
|
||||
yarn build-embed
|
||||
pnpm intl:build
|
||||
pnpm build-embed
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run all tests
|
||||
NODE_ENV=test yarn test
|
||||
NODE_ENV=test pnpm test
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run style checks and verify syntax
|
||||
yarn run lint
|
||||
pnpm run lint
|
||||
|
||||
#.PHONY: fmt
|
||||
#fmt: ## Run syntax re-formatting
|
||||
# yarn prettier
|
||||
# pnpm prettier
|
||||
|
||||
.PHONY: deps
|
||||
deps: ## Installs dependent libs using 'yarn install'
|
||||
yarn install --frozen-lockfile
|
||||
deps: ## Installs dependent libs using 'pnpm install'
|
||||
pnpm install --frozen-lockfile
|
||||
cd bskyembed && yarn install --frozen-lockfile
|
||||
|
||||
.PHONY: nvm-setup
|
||||
nvm-setup: ## Use NVM to install and activate node+yarn
|
||||
nvm-setup: ## Use NVM to install and activate node+pnpm
|
||||
nvm install 20
|
||||
nvm use 20
|
||||
npm install --global yarn
|
||||
npm install --global pnpm
|
||||
|
||||
@@ -262,6 +262,7 @@ module.exports = function (_config) {
|
||||
deploymentTarget: '15.1',
|
||||
buildReactNativeFromSource: true,
|
||||
ccacheEnabled: IS_DEV,
|
||||
cxxLanguageStandard: 'c++23',
|
||||
extraPods: [
|
||||
{
|
||||
name: 'MCEmojiPicker',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -47,6 +47,9 @@ module.exports = function (api) {
|
||||
production: {
|
||||
plugins: ['transform-remove-console'],
|
||||
},
|
||||
test: {
|
||||
plugins: ['@babel/plugin-transform-class-static-block'],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ Install dependencies:
|
||||
|
||||
```bash
|
||||
cd bskyembed
|
||||
yarn
|
||||
pnpm
|
||||
```
|
||||
|
||||
Run the dev server:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
pnpm 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
|
||||
yarn dev-snippet
|
||||
pnpm dev-snippet
|
||||
```
|
||||
|
||||
You can then see the testbed page at http://localhost:5173/test
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// @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 tseslint.config(
|
||||
export default defineConfig(
|
||||
// Global ignores
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
@@ -14,7 +16,11 @@ export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript rules with type checking
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
|
||||
// import-x
|
||||
importX.flatConfigs.recommended,
|
||||
importX.flatConfigs.typescript,
|
||||
|
||||
// Main configuration
|
||||
{
|
||||
|
||||
@@ -7,29 +7,33 @@
|
||||
"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"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier -w src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.25",
|
||||
"preact": "^10.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@vitejs/plugin-legacy": "^7.0.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@vitejs/plugin-legacy": "^8.0.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-simple-import-sort": "^13.0.0",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.4.38",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"prettier": "^3.8.3",
|
||||
"serve": "^14.2.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^8.0.12",
|
||||
"vite-bundle-analyzer": "^1.3.8"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/estree": "1.0.6"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
'@sentry/cli': true
|
||||
'core-js': true
|
||||
'esbuild': true
|
||||
'unrs-resolver': true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@atproto/*'
|
||||
- '@bsky.app/*'
|
||||
@@ -416,6 +416,7 @@ 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()}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES5",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"target": "ES2015",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"types": ["vite/client"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
@@ -17,7 +16,6 @@
|
||||
"jsx": "react",
|
||||
"jsxFactory": "h",
|
||||
"jsxFragmentFactory": "Fragment",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": ["src", "snippet", "vite.config.ts", "*.config.cjs", ".eslintrc.cjs"]
|
||||
"include": ["src", "snippet", "vite.config.ts", "*.config.cjs", "eslint.config.mjs"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES5",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"target": "ES2015",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"strict": true,
|
||||
"rootDir": "snippet",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["snippet"]
|
||||
|
||||
@@ -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 paths from 'vite-tsconfig-paths'
|
||||
import {analyzer} from 'vite-bundle-analyzer'
|
||||
|
||||
/**
|
||||
* World's hackiest router, for dev only. Serves `/post.html` to requests that start with `/embed/`
|
||||
@@ -38,11 +38,13 @@ 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',
|
||||
@@ -53,6 +55,9 @@ const config: UserConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
@@ -3,30 +3,38 @@
|
||||
"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": "node --watch-path ./src --import tsx ./src/bin.ts",
|
||||
"dev": "LOG_ENABLED=true LOG_LEVEL=debug node --watch-path ./src --import tsx ./src/bin.ts",
|
||||
"build": "tsc && cp -r src/assets dist/",
|
||||
"install-fonts": "tsx scripts/install-fonts.ts"
|
||||
"install-fonts": "tsx scripts/install-fonts.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.12.19-next.0",
|
||||
"@atproto/common": "^0.4.0",
|
||||
"@atproto/api": "0.20.6",
|
||||
"@atproto/common": "^0.6.1",
|
||||
"@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": "^18.3.1",
|
||||
"satori": "^0.10.13",
|
||||
"react": "^19.2.6",
|
||||
"satori": "^0.26.0",
|
||||
"twemoji": "^14.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
@@ -0,0 +1,526 @@
|
||||
/* 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>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {type 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
|
||||
@@ -17,7 +18,10 @@ export function StarterPack(props: {
|
||||
images: Map<string, Buffer>
|
||||
}) {
|
||||
const {starterPack, images} = props
|
||||
const record = AppBskyGraphStarterpack.isRecord(starterPack.record)
|
||||
const record = bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
|
||||
starterPack.record,
|
||||
AppBskyGraphStarterpack.isRecord,
|
||||
)
|
||||
? starterPack.record
|
||||
: null
|
||||
const imagesArray = [...images.values()]
|
||||
@@ -53,6 +57,7 @@ export function StarterPack(props: {
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 700,
|
||||
}}>
|
||||
{/* image tiles */}
|
||||
<div
|
||||
@@ -133,6 +138,7 @@ export function StarterPack(props: {
|
||||
fontSize: isLongTitle ? 55 : 65,
|
||||
display: 'flex',
|
||||
textAlign: 'center',
|
||||
textWrap: 'balance',
|
||||
}}>
|
||||
{record?.name || 'Starter Pack'}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ServiceConfig = {
|
||||
metricsPort: number
|
||||
version?: string
|
||||
appviewUrl: string
|
||||
chatUrl: string
|
||||
originVerify?: string
|
||||
}
|
||||
|
||||
@@ -17,6 +18,7 @@ export type Environment = {
|
||||
metricsPort?: number
|
||||
version?: string
|
||||
appviewUrl?: string
|
||||
chatUrl?: string
|
||||
originVerify?: string
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ 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'),
|
||||
}
|
||||
}
|
||||
@@ -36,6 +39,7 @@ 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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
|
||||
@@ -11,13 +12,15 @@ const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
|
||||
export type AppContextOptions = {
|
||||
cfg: Config
|
||||
appviewAgent: AtpAgent
|
||||
fonts: {name: string; data: Buffer}[]
|
||||
chatAgent: AtpAgent
|
||||
fonts: {name: string; data: Buffer; weight?: FontWeight}[]
|
||||
}
|
||||
|
||||
export class AppContext {
|
||||
cfg: Config
|
||||
appviewAgent: AtpAgent
|
||||
fonts: {name: string; data: Buffer}[]
|
||||
chatAgent: AtpAgent
|
||||
fonts: {name: string; data: Buffer; weight?: FontWeight}[]
|
||||
abortController = new AbortController()
|
||||
|
||||
private opts: AppContextOptions
|
||||
@@ -26,24 +29,46 @@ export class AppContext {
|
||||
this.opts = opts
|
||||
this.cfg = opts.cfg
|
||||
this.appviewAgent = opts.appviewAgent
|
||||
this.chatAgent = opts.chatAgent
|
||||
this.fonts = 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: path.basename(file, path.extname(file)),
|
||||
name: getName(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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,6 +2,7 @@ import {type 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 {default as health} from './health.js'
|
||||
import {default as starterPack} from './starter-pack.js'
|
||||
|
||||
@@ -10,6 +11,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
hideAvatarLabels,
|
||||
originVerifyMiddleware,
|
||||
} from './util.js'
|
||||
import * as bsky from '../types/bsky/index.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
@@ -29,9 +30,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.api.app.bsky.graph.getStarterPack(
|
||||
{starterPack: uri.toString()},
|
||||
)
|
||||
const result = await ctx.appviewAgent.app.bsky.graph.getStarterPack({
|
||||
starterPack: uri.toString(),
|
||||
})
|
||||
starterPack = result.data.starterPack
|
||||
} catch (err) {
|
||||
httpLogger.warn(
|
||||
@@ -41,7 +42,7 @@ export default function (ctx: AppContext, app: Express) {
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
const imageEntries = await Promise.all(
|
||||
[starterPack.creator]
|
||||
([starterPack.creator] as Array<bsky.profile.AnyProfileView>)
|
||||
.concat((starterPack.listItemsSample ?? []).map(li => li.subject))
|
||||
// has avatar
|
||||
.filter(p => p.avatar)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
@@ -10,5 +10,5 @@
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["./src/index.ts", "./src/bin.ts"]
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ like:
|
||||
# install nodejs
|
||||
nvm install
|
||||
nvm use
|
||||
npm install --global yarn
|
||||
npm install --global pnpm
|
||||
|
||||
# setup tools and deps (in top level of this repo)
|
||||
yarn install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# run yarn web dev server, if you wanted
|
||||
yarn web
|
||||
# run pnpm web dev server, if you wanted
|
||||
pnpm 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
|
||||
yarn build-web
|
||||
pnpm build-web
|
||||
```
|
||||
|
||||
### Golang Daemon
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ func run(args []string) {
|
||||
Required: false,
|
||||
EnvVars: []string{"OGCARD_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "chat-host",
|
||||
Usage: "scheme, hostname, and port of chat appview",
|
||||
Required: false,
|
||||
EnvVars: []string{"CHAT_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-address",
|
||||
Usage: "Specify the local IP/port to bind to",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,8 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %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))
|
||||
}
|
||||
if profileRequiresAuth(pv) {
|
||||
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))
|
||||
}
|
||||
@@ -76,10 +74,8 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %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))
|
||||
}
|
||||
if profileRequiresAuth(pv) {
|
||||
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)
|
||||
|
||||
@@ -17,11 +17,13 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
chat "github.com/bluesky-social/indigo/api/chat"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
"github.com/bluesky-social/indigo/util/cliutil"
|
||||
"github.com/bluesky-social/indigo/xrpc"
|
||||
@@ -37,10 +39,11 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
chatXrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
|
||||
ipccClient http.Client
|
||||
|
||||
@@ -54,6 +57,7 @@ type Config struct {
|
||||
debug bool
|
||||
httpAddress string
|
||||
appviewHost string
|
||||
chatHost string
|
||||
ogcardHost string
|
||||
linkHost string
|
||||
ipccHost string
|
||||
@@ -64,6 +68,7 @@ func serve(cctx *cli.Context) error {
|
||||
debug := cctx.Bool("debug")
|
||||
httpAddress := cctx.String("http-address")
|
||||
appviewHost := cctx.String("appview-host")
|
||||
chatHost := cctx.String("chat-host")
|
||||
ogcardHost := cctx.String("ogcard-host")
|
||||
linkHost := cctx.String("link-host")
|
||||
ipccHost := cctx.String("ipcc-host")
|
||||
@@ -83,6 +88,15 @@ func serve(cctx *cli.Context) error {
|
||||
Host: appviewHost,
|
||||
}
|
||||
|
||||
// optional client for the chat appview, used by /c/<code> for OG previews.
|
||||
var chatXrpcc *xrpc.Client
|
||||
if chatHost != "" {
|
||||
chatXrpcc = &xrpc.Client{
|
||||
Client: cliutil.NewHttpClient(),
|
||||
Host: chatHost,
|
||||
}
|
||||
}
|
||||
|
||||
// httpd
|
||||
var (
|
||||
httpTimeout = 2 * time.Minute
|
||||
@@ -106,12 +120,14 @@ func serve(cctx *cli.Context) error {
|
||||
// server
|
||||
//
|
||||
server := &Server{
|
||||
echo: e,
|
||||
xrpcc: xrpcc,
|
||||
echo: e,
|
||||
xrpcc: xrpcc,
|
||||
chatXrpcc: chatXrpcc,
|
||||
cfg: &Config{
|
||||
debug: debug,
|
||||
httpAddress: httpAddress,
|
||||
appviewHost: appviewHost,
|
||||
chatHost: chatHost,
|
||||
ogcardHost: ogcardHost,
|
||||
linkHost: linkHost,
|
||||
ipccHost: ipccHost,
|
||||
@@ -308,16 +324,6 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/settings/interests", server.WebGeneric)
|
||||
e.GET("/settings/about", server.WebGeneric)
|
||||
e.GET("/settings/notifications", server.WebGeneric)
|
||||
e.GET("/settings/notifications/replies", server.WebGeneric)
|
||||
e.GET("/settings/notifications/mentions", server.WebGeneric)
|
||||
e.GET("/settings/notifications/quotes", server.WebGeneric)
|
||||
e.GET("/settings/notifications/likes", server.WebGeneric)
|
||||
e.GET("/settings/notifications/reposts", server.WebGeneric)
|
||||
e.GET("/settings/notifications/new-followers", server.WebGeneric)
|
||||
e.GET("/settings/notifications/likes-on-reposts", server.WebGeneric)
|
||||
e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric)
|
||||
e.GET("/settings/notifications/activity", server.WebGeneric)
|
||||
e.GET("/settings/notifications/miscellaneous", server.WebGeneric)
|
||||
e.GET("/sys/debug", server.WebGeneric)
|
||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||
e.GET("/sys/log", server.WebGeneric)
|
||||
@@ -330,7 +336,10 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/intent/verify-email", server.WebGeneric)
|
||||
e.GET("/intent/age-assurance", server.WebGeneric)
|
||||
e.GET("/messages", server.WebGeneric)
|
||||
e.GET("/messages/inbox", server.WebGeneric)
|
||||
e.GET("/messages/:conversation", server.WebGeneric)
|
||||
e.GET("/messages/:conversation/settings", server.WebGeneric)
|
||||
e.GET("/messages/:conversation/requests", server.WebGeneric)
|
||||
|
||||
// profile endpoints; only first populates info
|
||||
e.GET("/profile/:handleOrDID", server.WebProfile)
|
||||
@@ -357,6 +366,9 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/starter-pack-short/:code", server.WebGeneric)
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
|
||||
// chat invites
|
||||
e.GET("/c/:code", server.WebChatInvite)
|
||||
|
||||
// bookmarks
|
||||
e.GET("/saved", server.WebGeneric)
|
||||
|
||||
@@ -500,6 +512,20 @@ var hideEmbedLabels = map[string]bool{
|
||||
"sensitive": true,
|
||||
}
|
||||
|
||||
// Replies surfaced into a post's JSON-LD comment[] are dropped entirely when
|
||||
// any of these labels are present (in addition to hideEmbedLabels). Targets
|
||||
// abuse/spam in third-party reply text, since reply text would otherwise be
|
||||
// emitted into the parent post's structured data.
|
||||
var hideReplyLabels = map[string]bool{
|
||||
"!hide": true,
|
||||
"!warn": true,
|
||||
"porn": true,
|
||||
"sexual": true,
|
||||
"nudity": true,
|
||||
"graphic-media": true,
|
||||
"spam": true,
|
||||
}
|
||||
|
||||
func (srv *Server) WebPost(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
data := srv.NewTemplateContext()
|
||||
@@ -524,17 +550,22 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
unauthedViewingOkay := true
|
||||
for _, label := range pv.Labels {
|
||||
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
|
||||
unauthedViewingOkay = false
|
||||
}
|
||||
}
|
||||
unauthedViewingOkay := !profileRequiresAuth(pv)
|
||||
|
||||
req := c.Request()
|
||||
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
|
||||
// Always prefer the handle-form URL so JSON-LD `url` and
|
||||
// <link rel="canonical"> match. Falls back to requestURI when the
|
||||
// handle is unusable (template strips query/fragment).
|
||||
canonicalURL := bskyPostURL(pv.Handle, rkey.String())
|
||||
|
||||
if !unauthedViewingOkay {
|
||||
// Provide minimal OpenGraph data for auth-required posts
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
data["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
data["canonicalURL"] = canonicalURL
|
||||
}
|
||||
data["requiresAuth"] = true
|
||||
data["profileHandle"] = pv.Handle
|
||||
if pv.DisplayName != nil {
|
||||
@@ -551,81 +582,44 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
|
||||
threadView := tpv.Thread.FeedDefs_ThreadViewPost
|
||||
if threadView == nil || threadView.Post == nil {
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
postView := threadView.Post
|
||||
data["postView"] = postView
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
data["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
data["canonicalURL"] = canonicalURL
|
||||
}
|
||||
|
||||
// If any undesirable labels are set, the embed will not be included in
|
||||
// metadata
|
||||
isEmbedHidden := false
|
||||
for _, label := range postView.Labels {
|
||||
isNeg := label.Neg != nil && *label.Neg
|
||||
if hideEmbedLabels[label.Val] && !isNeg {
|
||||
isEmbedHidden = true
|
||||
break
|
||||
// Share extraction helpers with jsonld.go so og:image and JSON-LD
|
||||
// image[] are byte-identical (per Google's requirement).
|
||||
isEmbedHidden := postEmbedHidden(postView, hideEmbedLabels)
|
||||
data["postText"] = postRecordText(postView)
|
||||
|
||||
if thumbs := extractPostMedia(postView, isEmbedHidden); len(thumbs) > 0 {
|
||||
data["imgThumbUrls"] = thumbs
|
||||
}
|
||||
if vm := extractVideoMeta(postView, isEmbedHidden); vm.URL != "" {
|
||||
data["videoUrl"] = vm.URL
|
||||
data["videoType"] = vm.Type
|
||||
if vm.HasSize {
|
||||
data["videoWidth"] = vm.Width
|
||||
data["videoHeight"] = vm.Height
|
||||
}
|
||||
}
|
||||
|
||||
if postView.Record != nil {
|
||||
postRecord, ok := postView.Record.Val.(*appbsky.FeedPost)
|
||||
if ok {
|
||||
data["postText"] = ExpandPostText(postRecord)
|
||||
|
||||
if !isEmbedHidden && postRecord.Labels != nil && postRecord.Labels.LabelDefs_SelfLabels != nil {
|
||||
for _, label := range postRecord.Labels.LabelDefs_SelfLabels.Values {
|
||||
if hideEmbedLabels[label.Val] {
|
||||
isEmbedHidden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build JSON-LD. Fall back to requestURI when handle is unusable so the
|
||||
// envelope url is never empty.
|
||||
jsonldURL := canonicalURL
|
||||
if jsonldURL == "" {
|
||||
jsonldURL = requestURI
|
||||
}
|
||||
|
||||
if postView.Embed != nil && !isEmbedHidden {
|
||||
hasImages := postView.Embed.EmbedImages_View != nil
|
||||
hasVideo := postView.Embed.EmbedVideo_View != nil
|
||||
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil
|
||||
hasMediaImages := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
|
||||
hasMediaVideo := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil
|
||||
|
||||
if hasImages {
|
||||
var thumbUrls []string
|
||||
for i := range postView.Embed.EmbedImages_View.Images {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if hasVideo {
|
||||
if postView.Embed.EmbedVideo_View.Thumbnail != nil {
|
||||
data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
if postView.Embed.EmbedVideo_View.Playlist != "" {
|
||||
data["videoUrl"] = postView.Embed.EmbedVideo_View.Playlist
|
||||
data["videoType"] = "application/vnd.apple.mpegurl"
|
||||
if postView.Embed.EmbedVideo_View.AspectRatio != nil {
|
||||
data["videoWidth"] = postView.Embed.EmbedVideo_View.AspectRatio.Width
|
||||
data["videoHeight"] = postView.Embed.EmbedVideo_View.AspectRatio.Height
|
||||
}
|
||||
}
|
||||
} else if hasMediaImages {
|
||||
var thumbUrls []string
|
||||
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if hasMediaVideo {
|
||||
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail != nil {
|
||||
data["imgThumbUrls"] = []string{*postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist != "" {
|
||||
data["videoUrl"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist
|
||||
data["videoType"] = "application/vnd.apple.mpegurl"
|
||||
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio != nil {
|
||||
data["videoWidth"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Width
|
||||
data["videoHeight"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Height
|
||||
}
|
||||
}
|
||||
}
|
||||
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
data["postJSONLD"] = jsonld
|
||||
} else {
|
||||
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
@@ -670,6 +664,43 @@ func (srv *Server) WebStarterPack(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
|
||||
// chatInviteCodeRe is a permissive sanity check on the join code so we don't
|
||||
// proxy obviously-bad input to the chat appview. Codes are opaque per the
|
||||
// lexicon, but in practice URL-safe and short.
|
||||
var chatInviteCodeRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
func (srv *Server) WebChatInvite(c echo.Context) error {
|
||||
req := c.Request()
|
||||
ctx := req.Context()
|
||||
data := srv.NewTemplateContext()
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
|
||||
code := c.Param("code")
|
||||
if !chatInviteCodeRe.MatchString(code) {
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
}
|
||||
if srv.chatXrpcc == nil {
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
}
|
||||
|
||||
out, err := chat.GroupGetJoinLinkPreviews(ctx, srv.chatXrpcc, []string{code})
|
||||
if err != nil {
|
||||
log.Errorf("failed to fetch chat invite preview for code=%s: %v", code, err)
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
}
|
||||
if len(out.JoinLinkPreviews) == 0 {
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
}
|
||||
preview := out.JoinLinkPreviews[0]
|
||||
|
||||
data["title"] = preview.Name
|
||||
if srv.cfg.ogcardHost != "" {
|
||||
// bskyogcard registers this route as /chat-invite/:code, not /c/:code.
|
||||
data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code)
|
||||
}
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebProfile(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
data := srv.NewTemplateContext()
|
||||
@@ -687,22 +718,56 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
||||
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
|
||||
return c.Render(http.StatusOK, "profile.html", data)
|
||||
}
|
||||
unauthedViewingOkay := true
|
||||
for _, label := range pv.Labels {
|
||||
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
|
||||
unauthedViewingOkay = false
|
||||
}
|
||||
}
|
||||
unauthedViewingOkay := !profileRequiresAuth(pv)
|
||||
|
||||
req := c.Request()
|
||||
data["profileView"] = pv
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
data["requestHost"] = req.Host
|
||||
|
||||
if !unauthedViewingOkay {
|
||||
// Prefer the handle-form URL so JSON-LD `url` and
|
||||
// <link rel="canonical"> match. Template falls back to requestURI
|
||||
// when the handle is unusable.
|
||||
if url := bskyProfileURL(pv.Handle); url != "" {
|
||||
data["canonicalURL"] = url
|
||||
}
|
||||
|
||||
// Fetch recent posts for ProfilePage.hasPart. Skipped for auth-required
|
||||
// profiles (posts aren't publicly indexable anyway). Failures degrade
|
||||
// gracefully — the profile still renders without hasPart.
|
||||
//
|
||||
// NOTE: extra XRPC call on every public profile render; consider
|
||||
// caching per-profile if upstream load becomes a concern.
|
||||
var recentPosts []*appbsky.FeedDefs_PostView
|
||||
if unauthedViewingOkay {
|
||||
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, pv.Did, "", "posts_no_replies", false, authorFeedFetchLimit)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch author feed for: %s\t%v", pv.Did, err)
|
||||
} else {
|
||||
for _, p := range af.Feed {
|
||||
if p == nil || p.Post == nil {
|
||||
continue
|
||||
}
|
||||
// Only the author's own posts (matches RSS handler).
|
||||
if p.Post.Author == nil || p.Post.Author.Did != pv.Did {
|
||||
continue
|
||||
}
|
||||
recentPosts = append(recentPosts, p.Post)
|
||||
if len(recentPosts) >= maxRecentPosts {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data["requiresAuth"] = true
|
||||
}
|
||||
|
||||
if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
data["profileJSONLD"] = jsonld
|
||||
} else {
|
||||
log.Warnf("failed to build profile JSON-LD for %s: %v", pv.Did, err)
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "profile.html", data)
|
||||
}
|
||||
|
||||
@@ -730,12 +795,7 @@ func (srv *Server) WebFeed(c echo.Context) error {
|
||||
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
|
||||
return c.Render(http.StatusOK, "feed.html", data)
|
||||
}
|
||||
unauthedViewingOkay := true
|
||||
for _, label := range pv.Labels {
|
||||
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
|
||||
unauthedViewingOkay = false
|
||||
}
|
||||
}
|
||||
unauthedViewingOkay := !profileRequiresAuth(pv)
|
||||
|
||||
if !unauthedViewingOkay {
|
||||
return c.Render(http.StatusOK, "feed.html", data)
|
||||
|
||||
@@ -95,7 +95,7 @@ func (srv *Server) parseBlueskyURL(ctx context.Context, raw string) (*syntax.ATU
|
||||
}
|
||||
var did syntax.DID
|
||||
if atid.IsHandle() {
|
||||
ident, err := srv.dir.Lookup(ctx, *atid)
|
||||
ident, err := srv.dir.Lookup(ctx, atid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a
|
||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0
|
||||
github.com/flosch/pongo2/v6 v6.0.0
|
||||
github.com/ipfs/go-log v1.0.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
@@ -18,11 +18,10 @@ require github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indir
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
||||
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
@@ -32,7 +31,6 @@ require (
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||
github.com/ipfs/go-block-format v0.2.0 // indirect
|
||||
@@ -82,7 +80,6 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
|
||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
|
||||
@@ -93,11 +90,11 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
|
||||
@@ -2,12 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a h1:S12KN45uIkRglMHC8PqD/Vsz0+u3KbIaBF/6rit8/Pg=
|
||||
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
|
||||
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
@@ -19,6 +15,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
|
||||
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
|
||||
@@ -48,8 +46,6 @@ github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
|
||||
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg=
|
||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
|
||||
@@ -211,8 +207,6 @@ github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSD
|
||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
||||
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
|
||||
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic=
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -253,8 +247,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -272,16 +266,16 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -297,8 +291,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -310,8 +304,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block html_head_extra -%}
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
{% endif -%}
|
||||
{%- if imgThumbUrl %}
|
||||
<meta property="og:image" content="{{ imgThumbUrl }}">
|
||||
<meta property="twitter:image" content="{{ imgThumbUrl }}">
|
||||
{%- else -%}
|
||||
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
{% endif -%}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{%- if title %}
|
||||
<meta property="og:title" content="{{ title }}" />
|
||||
<meta name="twitter:title" content="{{ title }}" />
|
||||
{%- else -%}
|
||||
<meta property="og:title" content="Bluesky" />
|
||||
<meta name="twitter:title" content="Bluesky" />
|
||||
{% endif -%}
|
||||
<meta name="description" content="Join this group chat on Bluesky" />
|
||||
<meta name="og:description" content="Join this group chat on Bluesky" />
|
||||
<meta name="twitter:description" content="Join this group chat on Bluesky" />
|
||||
{%- endblock %}
|
||||
@@ -15,8 +15,13 @@
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="profile:username" content="{{ postView.Author.Handle }}">
|
||||
{%- if requestURI %}
|
||||
{% if canonicalURL %}
|
||||
<meta property="og:url" content="{{ canonicalURL }}">
|
||||
<link rel="canonical" href="{{ canonicalURL }}" />
|
||||
{% else %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
|
||||
{% endif %}
|
||||
{% endif -%}
|
||||
{%- if postView.Author.DisplayName %}
|
||||
<meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
|
||||
@@ -34,6 +39,11 @@
|
||||
<meta property="twitter:image" content="{{ imgThumbUrl }}">
|
||||
{% endfor %}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
{% else %}
|
||||
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
|
||||
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif %}
|
||||
{%- if videoUrl %}
|
||||
<meta property="og:video" content="{{ videoUrl }}">
|
||||
<meta property="og:video:type" content="{{ videoType }}">
|
||||
@@ -42,11 +52,6 @@
|
||||
<meta property="og:video:height" content="{{ videoHeight }}">
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
{% else %}
|
||||
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
|
||||
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif %}
|
||||
<meta name="twitter:label1" content="Posted At">
|
||||
<meta name="twitter:value1" content="{{ postView.IndexedAt }}">
|
||||
{%- if postView.LikeCount %}
|
||||
@@ -64,52 +69,20 @@
|
||||
<meta property="article:published_time" content="{{ postView.IndexedAt }}">
|
||||
<link rel="alternate" type="application/json+oembed" href="https://embed.bsky.app/oembed?format=json&url={{ postView.Uri | urlencode }}" />
|
||||
<link rel="alternate" href="{{ postView.Uri }}" />
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "DiscussionForumPosting",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
{%- if postView.Author.DisplayName %}
|
||||
"name": "{{ postView.Author.DisplayName }}",
|
||||
"alternateName": "@{{ postView.Author.Handle }}",
|
||||
{% else %}
|
||||
"name": "@{{ postView.Author.Handle }}",
|
||||
{% endif -%}
|
||||
"url": "https://bsky.app/profile/{{ postView.Author.Handle }}"
|
||||
},
|
||||
{%- if postText %}
|
||||
"text": "{{ postText }}",
|
||||
{% endif %}
|
||||
{%- if imageThumbUrls %}
|
||||
"image": "{{ imageThumbUrls[0] }}",
|
||||
{% endif %}
|
||||
"datePublished": "{{ postView.IndexedAt }}",
|
||||
"interactionStatistic": [
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/LikeAction",
|
||||
"userInteractionCount": {{ postView.LikeCount }}
|
||||
},
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/CommentAction",
|
||||
"userInteractionCount": {{ postView.ReplyCount }}
|
||||
},
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/ShareAction",
|
||||
"userInteractionCount": {{ postView.RepostCount + postView.QuoteCount }}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
{%- if postJSONLD %}
|
||||
<script type="application/ld+json">{{ postJSONLD|safe }}</script>
|
||||
{% endif -%}
|
||||
{%- elif requiresAuth and profileHandle -%}
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="profile:username" content="{{ profileHandle }}">
|
||||
{%- if requestURI %}
|
||||
{% if canonicalURL %}
|
||||
<meta property="og:url" content="{{ canonicalURL }}">
|
||||
<link rel="canonical" href="{{ canonicalURL }}" />
|
||||
{% else %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
|
||||
{% endif %}
|
||||
{% endif -%}
|
||||
{%- if profileDisplayName %}
|
||||
<meta property="og:title" content="{{ profileDisplayName }} (@{{ profileHandle }})">
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
<meta property="og:site_name" content="Bluesky Social">
|
||||
<meta property="og:type" content="profile">
|
||||
{%- if requestURI %}
|
||||
{% if canonicalURL %}
|
||||
<meta property="og:url" content="{{ canonicalURL }}">
|
||||
<link rel="canonical" href="{{ canonicalURL }}" />
|
||||
{% else %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
|
||||
{% endif %}
|
||||
{% endif -%}
|
||||
|
||||
{%- if profileView -%}
|
||||
@@ -52,44 +57,9 @@
|
||||
<meta property="twitter:description" content="This profile requires authentication to view.">
|
||||
{% endif %}
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfilePage",
|
||||
"dateCreated": "{{ profileView.CreatedAt }}",
|
||||
"mainEntity": {
|
||||
"@type": "Person",
|
||||
{%- if profileView.DisplayName %}
|
||||
"name": "{{ profileView.DisplayName }}",
|
||||
"alternateName": "@{{ profileView.Handle }}",
|
||||
{% else %}
|
||||
"name": "@{{ profileView.Handle }}",
|
||||
{% endif -%}
|
||||
"identifier": "{{ profileView.Did }}",
|
||||
"description": "{{ profileView.Description }}",
|
||||
"image": "{{ profileView.Avatar }}",
|
||||
"interactionStatistic": [
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/FollowAction",
|
||||
"userInteractionCount": {{ profileView.FollowersCount }}
|
||||
}
|
||||
],
|
||||
"agentInteractionStatistic": [
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/FollowAction",
|
||||
"userInteractionCount": {{ profileView.FollowsCount }}
|
||||
},
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/WriteAction",
|
||||
"userInteractionCount": {{ profileView.PostsCount }}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{%- if profileJSONLD %}
|
||||
<script type="application/ld+json">{{ profileJSONLD|safe }}</script>
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
{%- endblock %}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "yarn install",
|
||||
"run": "yarn web --port $CONDUCTOR_PORT"
|
||||
"setup": "pnpm install --frozen-lockfile",
|
||||
"run": "pnpm web --port $CONDUCTOR_PORT"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {createServer as createHTTPServer} from 'node:http'
|
||||
import {parse} from 'node:url'
|
||||
|
||||
import {createServer, type TestPDS} from './test-pds'
|
||||
import {createServer, type TestPDS} from './test-pds.ts'
|
||||
|
||||
let server: TestPDS
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
@@ -97,6 +97,7 @@ createHTTPServer(async (req, res) => {
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
console.log('Generating mock posts')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let posts: Record<string, any[]> = {
|
||||
alice: [],
|
||||
bob: [],
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"name": "dev-env",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
|
||||
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/dev-env": "^0.3.215",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.2"
|
||||
"@atproto/api": "^0.20.0",
|
||||
"@atproto/dev-env": "^0.5.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
allowBuilds:
|
||||
'better-sqlite3': true
|
||||
'core-js': true
|
||||
'protobufjs': true
|
||||
'sharp': true
|
||||
'unrs-resolver': true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@atproto/*'
|
||||
- '@atproto-labs/*'
|
||||
@@ -1,8 +1,9 @@
|
||||
import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
|
||||
import {AtUri, BskyAgent} from '@atproto/api'
|
||||
import {type TestBsky, TestNetwork} from '@atproto/dev-env'
|
||||
import fs from 'fs'
|
||||
import net from 'net'
|
||||
import path from 'path'
|
||||
|
||||
export interface TestUser {
|
||||
email: string
|
||||
@@ -21,9 +22,11 @@ export interface TestPDS {
|
||||
|
||||
class StringIdGenerator {
|
||||
_nextId = [0]
|
||||
constructor(
|
||||
public _chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
) {}
|
||||
_chars: string
|
||||
|
||||
constructor(chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
||||
this._chars = chars
|
||||
}
|
||||
|
||||
next() {
|
||||
const r = []
|
||||
@@ -83,13 +86,13 @@ export async function createServer(
|
||||
// DISABLED - looks like dev-env added this and now it conflicts
|
||||
// add the test mod authority
|
||||
// const agent = new BskyAgent({service: pdsUrl})
|
||||
// const res = await agent.api.com.atproto.server.createAccount({
|
||||
// const res = await agent.com.atproto.server.createAccount({
|
||||
// email: 'mod-authority@test.com',
|
||||
// handle: 'mod-authority.test',
|
||||
// password: 'hunter2',
|
||||
// })
|
||||
// agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
||||
// await agent.api.app.bsky.actor.profile.create(
|
||||
// agent.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
||||
// await agent.app.bsky.actor.profile.create(
|
||||
// {repo: res.data.did},
|
||||
// {
|
||||
// displayName: 'Dev-env Moderation',
|
||||
@@ -97,7 +100,7 @@ export async function createServer(
|
||||
// },
|
||||
// )
|
||||
|
||||
// await agent.api.app.bsky.labeler.service.create(
|
||||
// await agent.app.bsky.labeler.service.create(
|
||||
// {repo: res.data.did, rkey: 'self'},
|
||||
// {
|
||||
// policies: {
|
||||
@@ -109,7 +112,7 @@ export async function createServer(
|
||||
// )
|
||||
|
||||
const pic = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
|
||||
path.join(import.meta.dirname, '..', 'assets', 'default-avatar.png'),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -125,12 +128,14 @@ export async function createServer(
|
||||
class Mocker {
|
||||
agent: BskyAgent
|
||||
users: Record<string, TestUser> = {}
|
||||
testNet: TestNetwork
|
||||
service: string
|
||||
pic: Uint8Array
|
||||
|
||||
constructor(
|
||||
public testNet: TestNetwork,
|
||||
public service: string,
|
||||
public pic: Uint8Array,
|
||||
) {
|
||||
constructor(testNet: TestNetwork, service: string, pic: Uint8Array) {
|
||||
this.testNet = testNet
|
||||
this.service = service
|
||||
this.pic = pic
|
||||
this.agent = new BskyAgent({service})
|
||||
}
|
||||
|
||||
@@ -161,7 +166,7 @@ class Mocker {
|
||||
async createUser(name: string) {
|
||||
const agent = new BskyAgent({service: this.service})
|
||||
|
||||
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||
const inviteRes = await agent.com.atproto.server.createInviteCode(
|
||||
{useCount: 1},
|
||||
{
|
||||
headers: this.pds.adminAuthHeaders(),
|
||||
@@ -309,20 +314,21 @@ class Mocker {
|
||||
'app.bsky.feed.generator',
|
||||
rkey,
|
||||
)
|
||||
// @ts-expect-error
|
||||
const fg1 = await this.testNet.createFeedGen({
|
||||
[fgUri.toString()]: async () => {
|
||||
[fgUri.toString()]: () => {
|
||||
return {
|
||||
encoding: 'application/json',
|
||||
encoding: 'application/json' as const,
|
||||
body: {
|
||||
feed: posts.slice(0, 30).map(uri => ({post: uri})),
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
const avatarRes = await agent.api.com.atproto.repo.uploadBlob(this.pic, {
|
||||
const avatarRes = await agent.com.atproto.repo.uploadBlob(this.pic, {
|
||||
encoding: 'image/png',
|
||||
})
|
||||
return await agent.api.app.bsky.feed.generator.create(
|
||||
return await agent.app.bsky.feed.generator.create(
|
||||
{repo: this.users[user].did, rkey},
|
||||
{
|
||||
did: fg1.did,
|
||||
@@ -336,7 +342,7 @@ class Mocker {
|
||||
|
||||
async createInvite(forAccount: string) {
|
||||
const agent = new BskyAgent({service: this.service})
|
||||
await agent.api.com.atproto.server.createInviteCode(
|
||||
await agent.com.atproto.server.createInviteCode(
|
||||
{useCount: 1, forAccount},
|
||||
{
|
||||
headers: this.pds.adminAuthHeaders(),
|
||||
|
||||
@@ -1,47 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "commonjs",
|
||||
"types": ["react-native", "jest"],
|
||||
"lib": [
|
||||
"es2019",
|
||||
"es2020.bigint",
|
||||
"es2020.date",
|
||||
"es2020.number",
|
||||
"es2020.promise",
|
||||
"es2020.string",
|
||||
"es2020.symbol.wellknown",
|
||||
"es2021.promise",
|
||||
"es2021.string",
|
||||
"es2021.weakref",
|
||||
"es2022.array",
|
||||
"es2022.object",
|
||||
"es2022.string"
|
||||
],
|
||||
"allowJs": true,
|
||||
"jsx": "react-native",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"types": ["node"],
|
||||
"lib": ["esnext"],
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"#/*": ["./src/*"],
|
||||
"lib/*": ["./src/lib/*"],
|
||||
"platform/*": ["./src/platform/*"],
|
||||
"state/*": ["./src/state/*"],
|
||||
"view/*": ["./src/view/*"],
|
||||
"crypto": ["./src/platform/crypto.ts"]
|
||||
}
|
||||
"erasableSyntaxOnly": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"babel.config.js",
|
||||
"metro.config.js",
|
||||
"jest.config.js"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Running Web App
|
||||
|
||||
- `yarn`
|
||||
- `yarn web`
|
||||
- `pnpm install`
|
||||
- `pnpm web`
|
||||
|
||||
You're all set!
|
||||
|
||||
@@ -32,11 +32,11 @@ This is NOT required when developing for web.
|
||||
- `bundler install` (this will install Cocoapods)
|
||||
- After initial setup:
|
||||
- Copy `google-services.json.example` to `google-services.json` or provide your own `google-services.json`. (A real firebase project is NOT required)
|
||||
- `npx expo prebuild` -> you will also need to run this anytime `app.json` or native `package.json` deps change
|
||||
- `pnpm prebuild` -> you will also need to run this anytime `app.json` or native `package.json` deps change
|
||||
|
||||
### Running the Native App
|
||||
|
||||
- iOS: `yarn ios`
|
||||
- iOS: `pnpm ios`
|
||||
- Xcode must be installed for this to run.
|
||||
- A simulator must be preconfigured in Xcode settings.
|
||||
- if no iOS versions are available, install the iOS runtime at `Xcode > Settings > Platforms`.
|
||||
@@ -48,24 +48,24 @@ This is NOT required when developing for web.
|
||||
- In addition, ensure Xcode Command Line Tools are installed using `xcode-select --install`.
|
||||
- Expo will require you to configure Xcode Signing. Follow the linked instructions. Error messages in Xcode related to the signing process can be safely ignored when installing on the iOS Simulator; Expo merely requires the profile to exist in order to install the app on the Simulator.
|
||||
- Make sure you do have a certificate: open Xcode > Settings > Accounts > (sign-in) > Manage Certificates > + > Apple Development > Done.
|
||||
- If you still encounter issues, try `rm -rf ios` before trying to build again (`yarn ios`)
|
||||
- Android: `yarn android`
|
||||
- If you still encounter issues, try `rm -rf ios` before trying to build again (`pnpm ios`)
|
||||
- Android: `pnpm android`
|
||||
- Install "Android Studio"
|
||||
- Make sure you have the Android SDK installed (Android Studio > Tools > Android SDK).
|
||||
- In "SDK Platforms": "Android x" (where x is Android's current version).
|
||||
- In "SDK Tools": "Android SDK Build-Tools" and "Android Emulator" are required.
|
||||
- Add `export ANDROID_HOME=/Users/<your_username>/Library/Android/sdk` to your `.zshrc` or `.bashrc` (and restart your terminal).
|
||||
- Setup an emulator (Android Studio > Tools > Device Manager).
|
||||
- Web: `yarn web` (see the top of this file).
|
||||
- Web: `pnpm web` (see the top of this file).
|
||||
|
||||
After you do `yarn ios` and `yarn android` once, you can later just run `yarn web` and then press either `i` or `a` to open iOS and Android emulators respectively which is much faster. However, if you make native changes, you'll have to do `yarn prebuild -p ios` and `yarn prebuild -p android` and then `yarn ios` and `yarn android` again before you can continue with the same workflow.
|
||||
After you do `pnpm ios` and `pnpm android` once, you can later just run `pnpm web` and then press either `i` or `a` to open iOS and Android emulators respectively which is much faster. However, if you make native changes, you'll have to do `pnpm prebuild -p ios` and `pnpm prebuild -p android` and then `pnpm ios` and `pnpm android` again before you can continue with the same workflow.
|
||||
|
||||
### Tips
|
||||
|
||||
- Copy the `.env.example` to `.env` and fill in any necessary tokens. (The Sentry token is NOT required; see instructions below if you want to enable Sentry.)
|
||||
- To run on the device, add `--device` to the command (e.g. `yarn android --device`). To build in production mode (slower build, faster app), also add `--variant release` on Android or `--configuration Release` on iOS.
|
||||
- To run on the device, add `--device` to the command (e.g. `pnpm android --device`). To build in production mode (slower build, faster app), also add `--variant release` on Android or `--configuration Release` on iOS.
|
||||
- If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` and `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
|
||||
- `npx react-native info` Checks what has been installed.
|
||||
- `pnpm exec react-native info` Checks what has been installed.
|
||||
- If the Android simulator frequently hangs or is very sluggish, [bump its memory limit](https://stackoverflow.com/a/40068396)
|
||||
- The Android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
|
||||
- For instance, the locally-hosted dev-wallet will need `adb reverse tcp:3001 tcp:3001`
|
||||
@@ -74,10 +74,10 @@ After you do `yarn ios` and `yarn android` once, you can later just run `yarn we
|
||||
### Running E2E Tests
|
||||
|
||||
- Start in various console tabs:
|
||||
- `yarn e2e:mock-server`
|
||||
- `yarn e2e:start`
|
||||
- Run once: `yarn e2e:build`
|
||||
- Each test run: `yarn e2e:run`
|
||||
- `pnpm e2e:mock-server`
|
||||
- `pnpm e2e:start`
|
||||
- Run once: `pnpm e2e:build`
|
||||
- Each test run: `pnpm e2e:run`
|
||||
|
||||
### Adding Sentry
|
||||
|
||||
@@ -85,7 +85,7 @@ Adding Sentry is NOT required. You can keep `SENTRY_AUTH_TOKEN=` in `.env` which
|
||||
|
||||
However, if you're a part of the Bluesky team and want to enable Sentry, fill in `SENTRY_AUTH_TOKEN` in your `.env`. It can be created on the Sentry dashboard using [these instructions](https://docs.expo.dev/guides/using-sentry/#sign-up-for-a-sentry-account-and-create-a-project).
|
||||
|
||||
If you change `SENTRY_AUTH_TOKEN`, you need to do `yarn prebuild` before running `yarn ios` or `yarn android` again.
|
||||
If you change `SENTRY_AUTH_TOKEN`, you need to do `pnpm prebuild` before running `pnpm ios` or `pnpm android` again.
|
||||
|
||||
### Adding bitdrift
|
||||
|
||||
@@ -95,7 +95,7 @@ However, if you're a part of the Bluesky team and want to enable bitdrift, fill
|
||||
|
||||
### Adding and Updating Locales
|
||||
|
||||
- `yarn intl:build` -> you will also need to run this anytime `./src/locale/{locale}/messages.po` change
|
||||
- `pnpm intl:build` -> you will also need to run this anytime `./src/locale/{locale}/messages.po` change
|
||||
|
||||
## Running the Backend Locally
|
||||
|
||||
@@ -112,7 +112,7 @@ This is NOT required for app development but if you also want to develop the Blu
|
||||
- Launch a Postgres database on port 5432
|
||||
- `cd packages/dev-env && pnpm start`
|
||||
|
||||
Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 yarn start`
|
||||
Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 pnpm start`
|
||||
|
||||
Then, when logging in or creating an account, point it to the localhost port of the devserver.
|
||||
|
||||
@@ -123,7 +123,7 @@ The Go server in this repository is only used for serving the web app in product
|
||||
### Prerequisites
|
||||
|
||||
- [Go](https://go.dev/)
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
- [pnpm](https://pnpm.io/)
|
||||
|
||||
### Steps
|
||||
|
||||
@@ -131,7 +131,7 @@ To run the build with Go, use staging credentials, your own, or any other accoun
|
||||
|
||||
```
|
||||
cd social-app
|
||||
yarn && yarn build-web
|
||||
pnpm install && pnpm build-web
|
||||
cd bskyweb/
|
||||
go mod tidy
|
||||
go build -v -tags timetzdata -o bskyweb ./cmd/bskyweb
|
||||
|
||||
@@ -42,20 +42,20 @@ Install the [Crowdin CLI](https://crowdin.github.io/crowdin-cli/). You will need
|
||||
|
||||
### English source-file sync with Crowdin
|
||||
|
||||
Every night, a GitHub action will run `yarn intl:extract` to update the english `messages.po` file. This will be automatically synced with Crowdin. Crowdin should notify all subscribed users of new translations.
|
||||
Every night, a GitHub action will run `pnpm intl:extract` to update the english `messages.po` file. This will be automatically synced with Crowdin. Crowdin should notify all subscribed users of new translations.
|
||||
|
||||
### Release process
|
||||
|
||||
1. Pull main and create a branch.
|
||||
1. Run `yarn intl:release` to fetch all translation updates from Crowdin and extract all `.po` files so that they're synced with the latest code. Commit that.
|
||||
1. Run `pnpm intl:release` to fetch all translation updates from Crowdin and extract all `.po` files so that they're synced with the latest code. Commit that.
|
||||
1. Create a PR, ensure the translations all look correct, and merge.
|
||||
1. If needed:
|
||||
1. Merge all approved translation PRs (contributions from outside crowdin).
|
||||
1. Run `yarn intl:push` to sync Crowdin with the state of the repo.
|
||||
1. Run `pnpm intl:push` to sync Crowdin with the state of the repo.
|
||||
|
||||
### Testing the translations in Crowdin
|
||||
|
||||
You can run `yarn intl:pull` to pull the currently-approved translations from Crowdin.
|
||||
You can run `pnpm intl:pull` to pull the currently-approved translations from Crowdin.
|
||||
|
||||
## Developers
|
||||
|
||||
@@ -118,15 +118,15 @@ function sayHello() {
|
||||
}
|
||||
```
|
||||
|
||||
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
|
||||
We can then run `yarn intl:compile` to update the translation files in `src/locale/locales/{locale}/messages.js`. This will add the new string to the translation files.
|
||||
We can then run `pnpm intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
|
||||
We can then run `pnpm intl:compile` to update the translation files in `src/locale/locales/{locale}/messages.js`. This will add the new string to the translation files.
|
||||
The configuration for translations is defined in `lingui.config.js`
|
||||
|
||||
So the workflow is as follows:
|
||||
1. Wrap messages in Trans macro
|
||||
2. Run `yarn intl:extract` command to generate message catalogs
|
||||
2. Run `pnpm intl:extract` command to generate message catalogs
|
||||
3. Translate message catalogs (send them to translators usually)
|
||||
4. Run `yarn intl:compile` to create runtime catalogs
|
||||
4. Run `pnpm intl:compile` to create runtime catalogs
|
||||
5. Load runtime catalog
|
||||
6. Enjoy translated app!
|
||||
|
||||
|
||||
@@ -25,13 +25,13 @@ adb reverse tcp:3000 tcp:3000
|
||||
|
||||
### Running Maestro tests
|
||||
|
||||
- In one tab, run `yarn e2e:mock-server`
|
||||
- In a second tab, run `yarn e2e:build`
|
||||
- In a third tab, run `yarn e2e:run`
|
||||
- In one tab, run `pnpm e2e:mock-server`
|
||||
- In a second tab, run `pnpm e2e:build`
|
||||
- In a third tab, run `pnpm e2e:run`
|
||||
|
||||
## Using Flashlight for Performance Testing
|
||||
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
|
||||
2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/)
|
||||
3. The simplest way to get started is by running `yarn perf:measure` which will run a live preview of the performance test results. You can [see a demo here](https://github.com/bamlab/flashlight/assets/4534323/4038a342-f145-4c3b-8cde-17949bf52612)
|
||||
4. The `yarn perf:test:measure` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml` and give the results in `.perf/results.json` which can be viewed by running `yarn:perf:results`
|
||||
5. You can also run your own tests by running `yarn perf:test <path_to_test>` where `<path_to_test>` is the path to your test file. For example, `yarn perf:test __e2e__/maestro/scroll.yaml` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml`.
|
||||
3. The simplest way to get started is by running `pnpm perf:measure` which will run a live preview of the performance test results. You can [see a demo here](https://github.com/bamlab/flashlight/assets/4534323/4038a342-f145-4c3b-8cde-17949bf52612)
|
||||
4. The `pnpm perf:test:measure` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml` and give the results in `.perf/results.json` which can be viewed by running `pnpm perf:results`
|
||||
5. You can also run your own tests by running `pnpm perf:test <path_to_test>` where `<path_to_test>` is the path to your test file. For example, `pnpm perf:test __e2e__/maestro/scroll.yaml` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 3.8.1",
|
||||
"version": ">=18",
|
||||
"promptToConfigurePushNotifications": false,
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @ts-check
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import {defineConfig} from 'eslint/config'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
// @ts-expect-error no types
|
||||
@@ -134,11 +134,10 @@ export default defineConfig(
|
||||
'react-native/no-inline-styles': 'off',
|
||||
...reactNativeA11y.configs.all.rules,
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
// TODO: Fix these and set to error
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
'react-hooks/refs': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'error',
|
||||
'react-hooks/purity': 'error',
|
||||
'react-hooks/refs': 'error',
|
||||
'react-hooks/immutability': 'error',
|
||||
|
||||
/**
|
||||
* Import sorting
|
||||
@@ -184,24 +183,30 @@ export default defineConfig(
|
||||
* Import linting
|
||||
*/
|
||||
'import-x/consistent-type-specifier-style': ['warn', 'prefer-inline'],
|
||||
'import-x/no-unresolved': ['error', {
|
||||
/*
|
||||
* The `postinstall` hook runs `compile-if-needed` locally, but not in
|
||||
* CI. For CI-sake, ignore this.
|
||||
*/
|
||||
ignore: ['^#\/locale\/locales\/.+\/messages'],
|
||||
}],
|
||||
'import-x/no-extraneous-dependencies': ['error', {
|
||||
'whitelist': [
|
||||
// test files only
|
||||
'@jest/globals',
|
||||
// we only use a really simple util from this, and we know it will be present
|
||||
'expo-modules-core',
|
||||
// this is a dep for @atproto/api, but we absolutely need them in sync, so just
|
||||
// rely on the transient version
|
||||
'@atproto/common-web',
|
||||
]
|
||||
}],
|
||||
'import-x/no-unresolved': [
|
||||
'error',
|
||||
{
|
||||
/*
|
||||
* The `postinstall` hook runs `compile-if-needed` locally, but not in
|
||||
* CI. For CI-sake, ignore this.
|
||||
*/
|
||||
ignore: ['^#\/locale\/locales\/.+\/messages'],
|
||||
},
|
||||
],
|
||||
'import-x/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
whitelist: [
|
||||
// test files only
|
||||
'@jest/globals',
|
||||
// we only use a really simple util from this, and we know it will be present
|
||||
'expo-modules-core',
|
||||
// this is a dep for @atproto/api, but we absolutely need them in sync, so just
|
||||
// rely on the transient version
|
||||
'@atproto/common-web',
|
||||
],
|
||||
},
|
||||
],
|
||||
'import-x/no-nodejs-modules': 'error',
|
||||
|
||||
/**
|
||||
@@ -222,13 +227,15 @@ export default defineConfig(
|
||||
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
|
||||
],
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': ['error', {
|
||||
allowTernary: true,
|
||||
}],
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowTernary: true,
|
||||
},
|
||||
],
|
||||
/**
|
||||
* Maintain previous behavior - these are stricter in typescript-eslint
|
||||
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
|
||||
* nit-picky
|
||||
* Maintain previous behavior via eslint-suppressions.json - these are
|
||||
* stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
|
||||
*/
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
@@ -238,26 +245,32 @@ export default defineConfig(
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-misused-promises': 'warn',
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
|
||||
'@typescript-eslint/await-thenable': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/require-await': 'error',
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'error',
|
||||
'@typescript-eslint/no-duplicate-type-constituents': 'error',
|
||||
'@typescript-eslint/no-base-to-string': 'error',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'error',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
|
||||
"no-restricted-imports": ["error", {
|
||||
"paths": [{
|
||||
"name": "react",
|
||||
"importNames": ["React", "default"],
|
||||
"message": "React is already in the global type namespace. Use named imports for runtime modules."
|
||||
}]
|
||||
}],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'react',
|
||||
importNames: ['React', 'default'],
|
||||
message:
|
||||
'React is already in the global type namespace. Use named imports for runtime modules.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Turn off rules that we haven't enforced thus far
|
||||
@@ -281,10 +294,10 @@ export default defineConfig(
|
||||
},
|
||||
|
||||
/**
|
||||
* bskyogcard - server-side, Node.js imports are fine
|
||||
* bskyogcard, dev-env - server-side, Node.js imports are fine
|
||||
*/
|
||||
{
|
||||
files: ['bskyogcard/**/*.{js,jsx,ts,tsx}'],
|
||||
files: ['bskyogcard/**/*.{js,jsx,ts,tsx}', 'dev-env/**/*.{js,jsx,ts,tsx}'],
|
||||
rules: {
|
||||
'import-x/no-nodejs-modules': 'off',
|
||||
},
|
||||
@@ -298,7 +311,7 @@ export default defineConfig(
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.jest,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -17,10 +17,6 @@ jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => {
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@fortawesome/react-native-fontawesome', () => ({
|
||||
FontAwesomeIcon: '',
|
||||
}))
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => {
|
||||
const inset = {top: 0, right: 0, bottom: 0, left: 0}
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const {getSentryExpoConfig} = require('@sentry/react-native/metro')
|
||||
const cfg = getSentryExpoConfig(__dirname)
|
||||
|
||||
// inject `.e2e.ts` and `.e2e.tsx` into the sourceExts when running tests
|
||||
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
|
||||
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
|
||||
: cfg.resolver.sourceExts
|
||||
@@ -16,25 +17,7 @@ if (process.env.BSKY_PROFILE) {
|
||||
|
||||
cfg.resolver.assetExts = [...cfg.resolver.assetExts, 'woff2']
|
||||
|
||||
// Enabled by default in RN 0.79+, but this breaks Lingui + others
|
||||
cfg.resolver.unstable_enablePackageExports = false
|
||||
|
||||
cfg.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
// HACK: manually resolve a few packages that use `exports` in `package.json`.
|
||||
// A proper solution is to enable `unstable_enablePackageExports` but this needs careful testing.
|
||||
if (moduleName.startsWith('multiformats/hashes/hasher')) {
|
||||
return context.resolveRequest(
|
||||
context,
|
||||
'multiformats/cjs/src/hashes/hasher',
|
||||
platform,
|
||||
)
|
||||
}
|
||||
if (moduleName.startsWith('multiformats/cid')) {
|
||||
return context.resolveRequest(context, 'multiformats/cjs/src/cid', platform)
|
||||
}
|
||||
if (moduleName === '@ipld/dag-cbor') {
|
||||
return context.resolveRequest(context, '@ipld/dag-cbor/src', platform)
|
||||
}
|
||||
if (process.env.BSKY_PROFILE) {
|
||||
if (moduleName.endsWith('ReactNativeRenderer-prod')) {
|
||||
return context.resolveRequest(
|
||||
@@ -51,16 +34,6 @@ cfg.transformer.getTransformOptions = async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: true,
|
||||
inlineRequires: true,
|
||||
nonInlinedRequires: [
|
||||
// We can remove this option and rely on the default after
|
||||
// https://github.com/facebook/metro/pull/1390 is released.
|
||||
'React',
|
||||
'react',
|
||||
'react-compiler-runtime',
|
||||
'react/jsx-dev-runtime',
|
||||
'react/jsx-runtime',
|
||||
'react-native',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -126,9 +126,9 @@ modules/BlueskyClip/
|
||||
|
||||
## Development Notes
|
||||
|
||||
- The App Clip is built as part of the main Xcode project when running `yarn prebuild`
|
||||
- The App Clip is built as part of the main Xcode project when running `pnpm prebuild`
|
||||
- Source files are copied during the prebuild process, not directly referenced
|
||||
- Changes to Swift files require running `yarn prebuild` to take effect
|
||||
- Changes to Swift files require running `pnpm prebuild` to take effect
|
||||
- The App Clip shares the same version number as the main app
|
||||
- App Clips have a 15MB size limit (enforced by Apple)
|
||||
- Users can convert an App Clip session into a full app install without losing data (via shared App Group)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
import Intents
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
let APP_GROUP = "group.app.bsky"
|
||||
typealias ContentHandler = (UNNotificationContent) -> Void
|
||||
@@ -30,11 +30,14 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
private var contentHandler: ContentHandler?
|
||||
private var bestAttempt: UNMutableNotificationContent?
|
||||
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
|
||||
guard let bestAttempt = NSEUtil.createCopy(request.content),
|
||||
let reason = request.content.userInfo["reason"] as? String
|
||||
let reason = request.content.userInfo["reason"] as? String
|
||||
else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
@@ -44,11 +47,26 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
if reason == "chat-message" || reason == "chat-reaction" {
|
||||
mutateWithChatMessage(bestAttempt)
|
||||
// Only apply the title->body swap for chat-message. For chat-reaction,
|
||||
// `messageKind` refers to the reacted-to message, so we'd clobber the
|
||||
// descriptive reaction body with the title for reactions on system
|
||||
// messages.
|
||||
if reason == "chat-message" {
|
||||
mutateChatMessageBody(bestAttempt, userInfo: request.content.userInfo)
|
||||
}
|
||||
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
|
||||
let finalContent = createCommunicationNotification(
|
||||
from: bestAttempt,
|
||||
userInfo: request.content.userInfo
|
||||
)
|
||||
contentHandler(finalContent)
|
||||
} else if reason == "chat-added-to-group"
|
||||
|| reason == "chat-removed-from-group"
|
||||
|| reason == "chat-join-request-rejected" {
|
||||
mutateWithChatMessage(bestAttempt)
|
||||
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
|
||||
mutateWithBadge(bestAttempt)
|
||||
contentHandler(bestAttempt)
|
||||
} else {
|
||||
mutateWithBadge(bestAttempt)
|
||||
contentHandler(bestAttempt)
|
||||
@@ -57,7 +75,8 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
guard let contentHandler = self.contentHandler,
|
||||
let bestAttempt = self.bestAttempt else {
|
||||
let bestAttempt = self.bestAttempt
|
||||
else {
|
||||
return
|
||||
}
|
||||
contentHandler(bestAttempt)
|
||||
@@ -71,7 +90,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
) -> UNNotificationContent {
|
||||
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
|
||||
let convoId = userInfo["convoId"] as? String
|
||||
var avatarImage: INImage? = nil
|
||||
var avatarImage: INImage?
|
||||
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
|
||||
avatarImage = downloadAvatarImage(from: avatarUrlString)
|
||||
}
|
||||
@@ -87,17 +106,53 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
customIdentifier: nil
|
||||
)
|
||||
|
||||
var speakableGroupName: INSpeakableString?
|
||||
if userInfo["convoKind"] as? String == "group",
|
||||
let groupName = userInfo["groupName"] as? String,
|
||||
!groupName.isEmpty {
|
||||
speakableGroupName = INSpeakableString(spokenPhrase: groupName)
|
||||
}
|
||||
|
||||
let intent = INSendMessageIntent(
|
||||
recipients: nil,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: content.body,
|
||||
speakableGroupName: nil,
|
||||
speakableGroupName: speakableGroupName,
|
||||
conversationIdentifier: convoId,
|
||||
serviceName: nil,
|
||||
sender: sender,
|
||||
attachments: nil
|
||||
)
|
||||
|
||||
// The push payload omits the full recipient list for group convos. Without
|
||||
// any signal of multiple recipients, iOS classifies the notification as
|
||||
// `MessagingDirect` and ignores `speakableGroupName`. Setting
|
||||
// `recipientCount` on the donation metadata is Apple's documented escape
|
||||
// hatch for this case, and overrides the (zero-length) recipients array.
|
||||
if userInfo["convoKind"] as? String == "group",
|
||||
let groupMemberCount = userInfo["groupMemberCount"] as? Int,
|
||||
groupMemberCount > 0 {
|
||||
let metadata = INSendMessageIntentDonationMetadata()
|
||||
metadata.recipientCount = groupMemberCount
|
||||
intent.donationMetadata = metadata
|
||||
}
|
||||
|
||||
// For group convos, attach the composite group avatar (rendered by the
|
||||
// ogcard service) to the `speakableGroupName` parameter so iOS shows it
|
||||
// alongside the sender on the Communication Notification.
|
||||
// Disabled: the composite avatar renders poorly at notification size.
|
||||
// if userInfo["convoKind"] as? String == "group",
|
||||
// let convoAvatarUrlString = userInfo["convoAvatarUrl"] as? String,
|
||||
// let groupImage = downloadAvatarImage(from: convoAvatarUrlString) {
|
||||
// intent.setImage(groupImage, forParameterNamed: \.speakableGroupName)
|
||||
// }
|
||||
|
||||
// Set the group image to the sender avatar instead
|
||||
if userInfo["convoKind"] as? String == "group",
|
||||
let avatarImage {
|
||||
intent.setImage(avatarImage, forParameterNamed: \.speakableGroupName)
|
||||
}
|
||||
|
||||
let interaction = INInteraction(intent: intent, response: nil)
|
||||
interaction.direction = .incoming
|
||||
interaction.donate(completion: nil)
|
||||
@@ -120,13 +175,13 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 5
|
||||
|
||||
var imageData: Data? = nil
|
||||
var imageData: Data?
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, _ in
|
||||
if let data = data,
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 {
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 {
|
||||
imageData = data
|
||||
}
|
||||
semaphore.signal()
|
||||
@@ -157,6 +212,40 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
}
|
||||
|
||||
// For group convos, surface the group name as the notification subtitle.
|
||||
// The sender's display name is shown as the title (overridden by
|
||||
// `INSendMessageIntent` for chat-message/chat-reaction).
|
||||
func mutateWithGroupSubtitle(
|
||||
_ content: UNMutableNotificationContent,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) {
|
||||
guard userInfo["convoKind"] as? String == "group",
|
||||
let groupName = userInfo["groupName"] as? String,
|
||||
!groupName.isEmpty
|
||||
else {
|
||||
return
|
||||
}
|
||||
content.subtitle = groupName
|
||||
}
|
||||
|
||||
// System messages (`add_member`, `convo_locked`, `edit_group`, etc.) are
|
||||
// delivered through `chat-message` but have a server-rendered description
|
||||
// in `title`. iOS overrides the title with the sender name once we apply
|
||||
// `INSendMessageIntent`, so we move the title text into the body before
|
||||
// building the intent.
|
||||
func mutateChatMessageBody(
|
||||
_ content: UNMutableNotificationContent,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) {
|
||||
guard let messageKind = userInfo["messageKind"] as? String,
|
||||
messageKind != "message",
|
||||
!content.title.isEmpty
|
||||
else {
|
||||
return
|
||||
}
|
||||
content.body = content.title
|
||||
}
|
||||
|
||||
func mutateWithDefaultSound(_ content: UNMutableNotificationContent) {
|
||||
content.sound = UNNotificationSound.default
|
||||
}
|
||||
|
||||