Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey fde5fb01dc Fix error styles 2024-05-16 16:12:41 -05:00
653 changed files with 42473 additions and 81856 deletions
-4
View File
@@ -13,7 +13,6 @@ module.exports = {
'lingui',
'simple-import-sort',
'bsky-internal',
'eslint-plugin-react-compiler',
],
rules: {
// Temporary until https://github.com/facebook/react-native/pull/43756 gets into a release.
@@ -31,7 +30,6 @@ module.exports = {
},
},
],
'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-typed-gates': 'error',
'simple-import-sort/imports': [
'warn',
@@ -69,8 +67,6 @@ module.exports = {
},
],
'simple-import-sort/exports': 'warn',
// TODO: Reenable when we figure out why it gets stuck on CI.
// 'react-compiler/react-compiler': 'error',
},
ignorePatterns: [
'**/__mocks__/*.ts',
@@ -1,53 +0,0 @@
name: build-and-push-link-aws
on:
workflow_dispatch:
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }}
IMAGE_NAME: bskylink
jobs:
link-container-aws:
if: github.repository == 'bluesky-social/social-app'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
password: ${{ env.PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
file: ./Dockerfile.bskylink
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -1,53 +0,0 @@
name: build-and-push-ogcard-aws
on:
workflow_dispatch:
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }}
IMAGE_NAME: bskyogcard
jobs:
ogcard-container-aws:
if: github.repository == 'bluesky-social/social-app'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
password: ${{ env.PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
file: ./Dockerfile.bskyogcard
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1 -46
View File
@@ -34,9 +34,6 @@ jobs:
node-version-file: .nvmrc
cache: yarn
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
@@ -99,17 +96,13 @@ jobs:
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Android production build for Google Play Store submission is ready!\n```Artifact: ${{ steps.upload-artifact-production.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"
"text": "Android build is ready for submission. This is a production build! Download the artifact here: ${{ steps.upload-artifact-production.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
@@ -126,41 +119,3 @@ jobs:
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: 🏗️ Build Production APK
if: ${{ inputs.profile == 'production' }}
run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
- name: 🚀 Upload Production APK Artifact
id: upload-artifact-production-apk
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 6
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 🔔 Notify Slack of Production APK Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
with:
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
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
if: ${{ inputs.profile == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ inputs.profile == 'testflight' }}
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
-35
View File
@@ -34,9 +34,6 @@ jobs:
node-version-file: .nvmrc
cache: yarn
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
@@ -50,10 +47,6 @@ jobs:
- name: ⚙️ Install dependencies
run: yarn install
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.3'
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
with:
@@ -83,31 +76,3 @@ jobs:
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
with:
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
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
if: ${{ inputs.profile == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ inputs.profile == 'testflight' }}
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
+63 -53
View File
@@ -26,7 +26,7 @@ jobs:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy
cancel-in-progress: true
outputs:
changes-detected: ${{ steps.fingerprint.outputs.includes-changes }}
fingerprint-is-different: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different }}
steps:
- name: Check for EXPO_TOKEN
@@ -49,22 +49,69 @@ jobs:
with:
fetch-depth: 0
- name: ⬇️ Get last successful deployment commit from the cache
id: get-base-commit
uses: actions/cache@v4
with:
path: last-successful-commit-hash.txt
key: last-successful-deployment-commit-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
last-successful-deployment-commit-${{ github.ref_name }}-
- name: Add the last successful deployment commit to the output
id: last-successful-commit
run: echo base-commit=$(cat last-successful-commit-hash.txt) >> "$GITHUB_OUTPUT"
- name: ⬇️ Fetch commits from base branch
if: ${{ github.ref != 'refs/heads/main' }}
run: git fetch origin main:main --depth 100
# This should get the current production release's commit's hash to see if the update is compatible
- name: 🕵️ Get the base commit
id: base-commit
run: |
if ${{ inputs.channel == 'production' }}; then
echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT"
else
echo base-commit=${{ steps.last-successful-commit.base-commit }} >> "$GITHUB_OUTPUT"
fi
- name: ✓ Make sure we found a base commit
run: |
if [ -z "${{ steps.base-commit.outputs.base-commit }}" && ${{ inputs.channel == 'production' }} ]; then
echo "Could not find a base commit for this release. Exiting."
exit 1
fi
- name: 🔧 Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: 📷 Check fingerprint and install dependencies
- name: ⚙️ Install Dependencies
run: yarn install
# Run the fingerprint
- name: 📷 Check fingerprint
id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@main
uses: expo/expo-github-action/fingerprint@main
with:
profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }}
previous-git-commit: ${{ steps.base-commit.outputs.base-commit }}
- name: 👀 Debug fingerprint
id: fingerprint-debug
run: |
echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}"
echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}"
fingerprintDiff='$(echo "${{ steps.fingerprint.outputs.fingerprint-diff }}")'
if [[ $fingerprintDiff =~ "bareRncliAutolinking" || $fingerprintDiff =~ "expoAutolinkingAndroid" || $fingerprintDiff =~ "expoAutolinkingIos" ]]; then
echo fingerprint-is-different="true" >> "$GITHUB_OUTPUT"
else
echo fingerprint-is-different="false" >> "$GITHUB_OUTPUT"
fi
- name: Lint check
run: yarn lint
@@ -80,22 +127,22 @@ jobs:
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
if: ${{ !steps.fingerprint.outputs.includes-changes }}
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup Expo
if: ${{ !steps.fingerprint.outputs.includes-changes }}
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: yarn global add eas-cli-local-build-plugin
- name: 🪛 Setup jq
if: ${{ !steps.fingerprint.outputs.includes-changes }}
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
uses: dcarbone/install-jq-action@v2
- name: ✏️ Write environment variables
if: ${{ !steps.fingerprint.outputs.includes-changes }}
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
@@ -104,28 +151,19 @@ jobs:
echo "$json" > google-services.json
- name: 🏗️ Create Bundle
if: ${{ !steps.fingerprint.outputs.includes-changes }}
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
- name: 📦 Package Bundle and 🚀 Deploy
if: ${{ !steps.fingerprint.outputs.includes-changes }}
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: yarn use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
- name: Save successful deployment commit hash
run: echo ${{ steps.fingerprint.outputs.current-git-commit }} > last-successful-commit-hash.txt
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
@@ -133,11 +171,11 @@ jobs:
runs-on: macos-14
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
cancel-in-progress: true
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected }}
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-is-different == 'true' }}
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -170,10 +208,6 @@ jobs:
- name: ⚙️ Install dependencies
run: yarn install
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.3'
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
with:
@@ -204,18 +238,6 @@ jobs:
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
if: ${{ inputs.channel == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ inputs.channel == 'testflight' }}
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
buildIfNecessaryAndroid:
name: Build and Submit Android
runs-on: ubuntu-latest
@@ -225,7 +247,7 @@ jobs:
needs: [ bundleDeploy ]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected }}
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-is-different == 'true' }}
steps:
- name: Check for EXPO_TOKEN
@@ -303,15 +325,3 @@ jobs:
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- name: Set up Go tooling
uses: actions/setup-go@v3
with:
go-version: '1.22'
go-version: '1.21'
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
- name: Check
@@ -36,7 +36,7 @@ jobs:
- name: Set up Go tooling
uses: actions/setup-go@v3
with:
go-version: '1.22'
go-version: '1.21'
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
- name: Lint
+24 -11
View File
@@ -1,7 +1,7 @@
# Credit for fingerprint action https://github.com/expo/expo
# https://github.com/expo/expo/blob/main/.github/workflows/pr-labeler.yml
---
name: PR Tests
name: PR labeler
on:
push:
@@ -21,7 +21,7 @@ permissions:
jobs:
webpack-analyzer:
runs-on: ubuntu-22.04
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
@@ -94,9 +94,10 @@ jobs:
| ${{ steps.get-diff.outputs.base_file_string }} | ${{ steps.get-diff.outputs.pr_file_string }} | ${{ steps.get-diff.outputs.diff_file_string }} (${{ steps.get-diff.outputs.percent }}%) |
---
fingerprint-native:
test-suite-fingerprint:
runs-on: ubuntu-22.04
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' }}
concurrency: fingerprint-${{ github.event_name != 'pull_request' && 'main' || github.run_id }}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
@@ -113,23 +114,35 @@ jobs:
node-version-file: .nvmrc
cache: yarn
- name: 📷 Check fingerprint and install dependencies
- name: ⚙️ Install Dependencies
run: yarn install
- name: Get the base commit
id: base-commit
run: echo base-commit=$(git log -n 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT"
- name: 📷 Check fingerprint
id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@main
uses: expo/expo-github-action/fingerprint@main
with:
profile: pull-request
previous-git-commit: ${{ steps.base-commit.outputs.base-commit }}
- name: 👀 Debug fingerprint
run: |
echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}"
echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}"
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ steps.fingerprint.outputs.includes-changes }}
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' }}
with:
header: fingerprint-diff
message: |
The Pull Request introduced fingerprint changes against the base commit:
The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }}
<details><summary>Fingerprint diff</summary>
```json
${{ steps.fingerprint.outputs.diff }}
${{ steps.fingerprint.outputs.fingerprint-diff }}
```
</details>
@@ -139,7 +152,7 @@ jobs:
- name: 💬 Delete comment
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ !steps.fingerprint.outputs.includes-changes }}
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' }}
with:
header: fingerprint-diff
delete: true
-3
View File
@@ -115,6 +115,3 @@ src/locale/locales/**/*.js
*.apk
*.aab
*.ipa
# ogcard assets
bskyogcard/src/assets/fonts/noto-*
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
yarn lint-staged
-1
View File
@@ -12,4 +12,3 @@
android
ios
src/locale/locales
lib/react-compiler-runtime
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.22-bullseye AS build-env
FROM golang:1.21-bullseye AS build-env
WORKDIR /usr/src/social-app
-41
View File
@@ -1,41 +0,0 @@
FROM node:20.11-alpine3.18 as build
# Move files into the image and install
WORKDIR /app
COPY ./bskylink/package.json ./
COPY ./bskylink/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY ./bskylink ./
# build then prune dev deps
RUN yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:20.11-alpine3.18
RUN apk add --update dumb-init
# Avoid zombie processes, handle signal forwarding
ENTRYPOINT ["dumb-init", "--"]
WORKDIR /app
COPY --from=build /app /app
RUN mkdir /app/data && chown node /app/data
VOLUME /app/data
EXPOSE 3000
ENV LINK_PORT=3000
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"]
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
LABEL org.opencontainers.image.description="Bsky Link Service"
LABEL org.opencontainers.image.licenses=UNLICENSED
-41
View File
@@ -1,41 +0,0 @@
FROM node:20.11-alpine3.18 as build
# Move files into the image and install
WORKDIR /app
COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY ./bskyogcard ./
# build then prune dev deps
RUN yarn install-fonts && yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:20.11-alpine3.18
RUN apk add --update dumb-init
# Avoid zombie processes, handle signal forwarding
ENTRYPOINT ["dumb-init", "--"]
WORKDIR /app
COPY --from=build /app /app
RUN mkdir /app/data && chown node /app/data
VOLUME /app/data
EXPOSE 3000
ENV CARD_PORT=3000
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"]
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
LABEL org.opencontainers.image.description="Bsky Card Service"
LABEL org.opencontainers.image.licenses=UNLICENSED
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.22-bullseye AS build-env
FROM golang:1.21-bullseye AS build-env
WORKDIR /usr/src/social-app
+7 -4
View File
@@ -131,8 +131,9 @@ appId: xyz.blueskyweb.app
- assertVisible:
id: "feedItem-by-bob.test"
- tapOn:
id: "e2eGotoFeeds"
- tapOn: "Good Ppl"
id: "bottomBarFeedsBtn"
- tapOn:
id: "saved-feed-Good Ppl"
- assertVisible:
id: "feedItem-by-bob.test"
- tapOn:
@@ -143,7 +144,8 @@ appId: xyz.blueskyweb.app
id: "homeScreenFeedTabs-Good Ppl"
- tapOn:
id: "e2eGotoLists"
- tapOn: "Good Ppl"
- tapOn:
id: "list-Good Ppl"
- tapOn: "About"
- assertVisible:
@@ -169,7 +171,8 @@ appId: xyz.blueskyweb.app
direction: LEFT
- tapOn:
id: "profilePager-selector-5"
- tapOn: "Good Ppl"
- tapOn:
id: "list-Good Ppl"
- tapOn:
label: "Adds and removes users on curatelists from the profile"
+6 -16
View File
@@ -18,7 +18,8 @@ appId: xyz.blueskyweb.app
direction: LEFT
- tapOn:
id: "profilePager-selector-4"
- tapOn: "alice-favs"
- tapOn:
id: "feed-alice-favs"
- tapOn: "Pin to Home"
- tapOn:
id: "bottomBarHomeBtn"
@@ -35,15 +36,12 @@ appId: xyz.blueskyweb.app
id: "viewHeaderDrawerBtn"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- tapOn: "Edit Saved Feeds"
- tapOn:
label: "Tap on down arrow"
point: "79%,23%"
- tapOn:
id: "viewHeaderDrawerBtn"
- tapOn:
id: "viewHeaderDrawerBtn"
id: "bottomBarHomeBtn"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "alice-favs"
@@ -56,15 +54,11 @@ appId: xyz.blueskyweb.app
id: "viewHeaderDrawerBtn"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- tapOn:
label: "Tap on down arrow"
point: "79%,23%"
- tapOn:
id: "viewHeaderDrawerBtn"
- tapOn:
id: "viewHeaderDrawerBtn"
id: "bottomBarHomeBtn"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "Following"
@@ -77,15 +71,11 @@ appId: xyz.blueskyweb.app
id: "viewHeaderDrawerBtn"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- tapOn:
label: "Tap on unpin"
point: "91%,23%"
- tapOn:
id: "viewHeaderDrawerBtn"
- tapOn:
id: "viewHeaderDrawerBtn"
id: "bottomBarHomeBtn"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "alice-favs"
+3 -4
View File
@@ -23,12 +23,11 @@ appId: xyz.blueskyweb.app
direction: LEFT
- tapOn:
id: "profilePager-selector-4"
- tapOn: "alice-favs"
- tapOn:
id: "feed-alice-favs"
- tapOn: "Pin to Home"
- tapOn:
id: "bottomBarHomeBtn"
- tapOn:
id: "viewHeaderDrawerBtn"
- assertNotVisible: "Feeds ✨"
- tapOn:
@@ -51,7 +50,7 @@ appId: xyz.blueskyweb.app
text: "1"
- tapOn:
id: "repostBtn"
- tapOn: "Remove repost"
- tapOn: "Undo repost"
- assertNotVisible:
id: "repostCount"
+31
View File
@@ -0,0 +1,31 @@
# Remove this test when the old onboarding is deprecated
appId: xyz.blueskyweb.app
---
- runScript:
file: ../setupServer.js
env:
SERVER_PATH: "?users"
- runFlow:
file: ../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn:
id: "e2eStartLongboarding"
- tapOn: "Continue to next step"
- tapOn: "Continue to the next step without following any accounts"
- tapOn: Show replies in Following feed
- tapOn: Show quote-posts in Following feed
- tapOn: Show re-posts in Following feed
- tapOn: Show replies in Following feed
- waitForAnimationToEnd
- tapOn: Continue to next step
- waitForAnimationToEnd
- tapOn: "Continue to the next step"
- waitForAnimationToEnd
- tapOn: Continue to next step
- waitForAnimationToEnd
- tapOn: Continue to next step
- waitForAnimationToEnd
- tapOn: "Complete onboarding and start using your account"
- waitForAnimationToEnd
- assertVisible: Following
+2 -1
View File
@@ -21,7 +21,8 @@ appId: xyz.blueskyweb.app
direction: LEFT
- tapOn:
id: "profilePager-selector-4"
- assertVisible: "alice-favs"
- assertVisible:
id: "feed-alice-favs"
- swipe:
from:
id: "profilePager-selector"
+2 -2
View File
@@ -58,7 +58,7 @@ appId: xyz.blueskyweb.app
id: "repostBtn"
childOf:
id: "postThreadItem-by-bob.test"
- tapOn: "Remove repost"
- tapOn: "Undo repost"
- assertNotVisible:
id: "repostCount-expanded"
@@ -77,7 +77,7 @@ appId: xyz.blueskyweb.app
id: "repostBtn"
childOf:
id: "postThreadItem-by-carla.test"
- tapOn: "Remove repost"
- tapOn: "Undo repost"
- assertNotVisible:
id: "repostCount"
childOf:
+73 -261
View File
@@ -1,16 +1,12 @@
import {RichText} from '@atproto/api'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
import {
createStarterPackGooglePlayUri,
createStarterPackLinkFromAndroidReferrer,
parseStarterPackUri,
} from 'lib/strings/starter-pack'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {ago} from '../../src/lib/strings/time'
import {
makeRecordUri,
toNiceDomain,
@@ -146,6 +142,78 @@ describe('makeRecordUri', () => {
})
})
describe('ago', () => {
const oneYearDate = new Date(
new Date().setMonth(new Date().getMonth() - 11),
).setDate(new Date().getDate() - 28)
const inputs = [
1671461038,
'04 Dec 1995 00:12:00 GMT',
new Date(),
new Date().setSeconds(new Date().getSeconds() - 10),
new Date().setMinutes(new Date().getMinutes() - 10),
new Date().setHours(new Date().getHours() - 1),
new Date().setDate(new Date().getDate() - 1),
new Date().setDate(new Date().getDate() - 20),
new Date().setDate(new Date().getDate() - 25),
new Date().setDate(new Date().getDate() - 28),
new Date().setDate(new Date().getDate() - 29),
new Date().setDate(new Date().getDate() - 30),
new Date().setMonth(new Date().getMonth() - 1),
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
new Date().getDate() - 20,
),
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
new Date().getDate() - 25,
),
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
new Date().getDate() - 28,
),
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
new Date().getDate() - 29,
),
new Date().setMonth(new Date().getMonth() - 11),
new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
new Date().getDate() - 20,
),
new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
new Date().getDate() - 25,
),
oneYearDate,
]
const outputs = [
new Date(1671461038).toLocaleDateString(),
new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
'now',
'10s',
'10m',
'1h',
'1d',
'20d',
'25d',
'28d',
'29d',
'1mo',
'1mo',
'1mo',
'1mo',
'2mo',
'2mo',
'11mo',
'11mo',
'11mo',
new Date(oneYearDate).toLocaleDateString(),
]
it('correctly calculates how much time passed, in a string', () => {
for (let i = 0; i < inputs.length; i++) {
const result = ago(inputs[i])
expect(result).toEqual(outputs[i])
}
})
})
describe('makeValidHandle', () => {
const inputs = [
'test-handle-123',
@@ -411,26 +479,6 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://media.tenor.com/someID/someName.gif',
'https://media.tenor.com/someID',
'https://media.tenor.com',
'https://www.flickr.com/photos/username/albums/72177720308493661',
'https://flickr.com/photos/username/albums/72177720308493661',
'https://flickr.com/photos/username/albums/72177720308493661/',
'https://flickr.com/photos/username/albums/72177720308493661//',
'https://flic.kr/s/aHBqjAES3i',
'https://flickr.com/foetoes/username/albums/3903',
'https://flickr.com/albums/3903',
'https://flic.kr/s/OolI',
'https://flic.kr/t/aHBqjAES3i',
'https://www.flickr.com/groups/898944@N23/pool',
'https://flickr.com/groups/898944@N23/pool',
'https://flickr.com/groups/898944@N23/pool/',
'https://flickr.com/groups/898944@N23/pool//',
'https://flic.kr/go/8WJtR',
'https://www.flickr.com/groups/898944@N23/',
'https://www.flickr.com/groups',
]
const outputs = [
@@ -728,66 +776,6 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
undefined,
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
undefined,
undefined,
undefined,
undefined,
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
@@ -801,179 +789,3 @@ describe('parseEmbedPlayerFromUrl', () => {
}
})
})
describe('createStarterPackLinkFromAndroidReferrer', () => {
const validOutput = 'at://haileyok.com/app.bsky.graph.starterpack/rkey'
it('returns a link when input contains utm_source and utm_content', () => {
expect(
createStarterPackLinkFromAndroidReferrer(
'utm_source=bluesky&utm_content=starterpack_haileyok.com_rkey',
),
).toEqual(validOutput)
expect(
createStarterPackLinkFromAndroidReferrer(
'utm_source=bluesky&utm_content=starterpack_test-lover-9000.com_rkey',
),
).toEqual('at://test-lover-9000.com/app.bsky.graph.starterpack/rkey')
})
it('returns a link when input contains utm_source and utm_content in different order', () => {
expect(
createStarterPackLinkFromAndroidReferrer(
'utm_content=starterpack_haileyok.com_rkey&utm_source=bluesky',
),
).toEqual(validOutput)
})
it('returns a link when input contains other parameters as well', () => {
expect(
createStarterPackLinkFromAndroidReferrer(
'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack_haileyok.com_rkey',
),
).toEqual(validOutput)
})
it('returns null when utm_source is not present', () => {
expect(
createStarterPackLinkFromAndroidReferrer(
'utm_content=starterpack_haileyok.com_rkey',
),
).toEqual(null)
})
it('returns null when utm_content is not present', () => {
expect(
createStarterPackLinkFromAndroidReferrer('utm_source=bluesky'),
).toEqual(null)
})
it('returns null when utm_content is malformed', () => {
expect(
createStarterPackLinkFromAndroidReferrer(
'utm_content=starterpack_haileyok.com',
),
).toEqual(null)
expect(
createStarterPackLinkFromAndroidReferrer('utm_content=starterpack'),
).toEqual(null)
expect(
createStarterPackLinkFromAndroidReferrer(
'utm_content=starterpack_haileyok.com_rkey_more',
),
).toEqual(null)
expect(
createStarterPackLinkFromAndroidReferrer(
'utm_content=notastarterpack_haileyok.com_rkey',
),
).toEqual(null)
})
})
describe('parseStarterPackHttpUri', () => {
const baseUri = 'https://bsky.app/start'
it('returns a valid at uri when http uri is valid', () => {
const validHttpUri = `${baseUri}/haileyok.com/rkey`
expect(parseStarterPackUri(validHttpUri)).toEqual({
name: 'haileyok.com',
rkey: 'rkey',
})
const validHttpUri2 = `${baseUri}/haileyok.com/ilovetesting`
expect(parseStarterPackUri(validHttpUri2)).toEqual({
name: 'haileyok.com',
rkey: 'ilovetesting',
})
const validHttpUri3 = `${baseUri}/testlover9000.com/rkey`
expect(parseStarterPackUri(validHttpUri3)).toEqual({
name: 'testlover9000.com',
rkey: 'rkey',
})
})
it('returns null when there is no rkey', () => {
const validHttpUri = `${baseUri}/haileyok.com`
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
})
it('returns null when there is an extra path', () => {
const validHttpUri = `${baseUri}/haileyok.com/rkey/other`
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
})
it('returns null when there is no handle or rkey', () => {
const validHttpUri = `${baseUri}`
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
})
it('returns null when the route is not /start or /starter-pack', () => {
const validHttpUri = 'https://bsky.app/start/haileyok.com/rkey'
expect(parseStarterPackUri(validHttpUri)).toEqual({
name: 'haileyok.com',
rkey: 'rkey',
})
const validHttpUri2 = 'https://bsky.app/starter-pack/haileyok.com/rkey'
expect(parseStarterPackUri(validHttpUri2)).toEqual({
name: 'haileyok.com',
rkey: 'rkey',
})
const invalidHttpUri = 'https://bsky.app/profile/haileyok.com/rkey'
expect(parseStarterPackUri(invalidHttpUri)).toEqual(null)
})
it('returns the at uri when the input is a valid starterpack at uri', () => {
const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey'
expect(parseStarterPackUri(validAtUri)).toEqual({
name: 'did:123',
rkey: 'rkey',
})
})
it('returns null when the at uri has no rkey', () => {
const validAtUri = 'at://did:123/app.bsky.graph.starterpack'
expect(parseStarterPackUri(validAtUri)).toEqual(null)
})
it('returns null when the collection is not app.bsky.graph.starterpack', () => {
const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
expect(parseStarterPackUri(validAtUri)).toEqual(null)
})
it('returns null when the input is undefined', () => {
expect(parseStarterPackUri(undefined)).toEqual(null)
})
})
describe('createStarterPackGooglePlayUri', () => {
const base =
'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_'
it('returns valid google play uri when input is valid', () => {
expect(createStarterPackGooglePlayUri('name', 'rkey')).toEqual(
`${base}name_rkey`,
)
})
it('returns null when no rkey is supplied', () => {
// @ts-expect-error test
expect(createStarterPackGooglePlayUri('name', undefined)).toEqual(null)
})
it('returns null when no name or rkey are supplied', () => {
// @ts-expect-error test
expect(createStarterPackGooglePlayUri(undefined, undefined)).toEqual(null)
})
it('returns null when rkey is supplied but no name', () => {
// @ts-expect-error test
expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null)
})
})
+3 -18
View File
@@ -39,15 +39,6 @@ module.exports = function (config) {
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
const ASSOCIATED_DOMAINS = [
'applinks:bsky.app',
'applinks:staging.bsky.app',
'appclips:bsky.app',
'appclips:go.bsky.app', // Allows App Clip to work when scanning QR codes
// When testing local services, enter an ngrok (et al) domain here. It must use a standard HTTP/HTTPS port.
...(IS_DEV || IS_TESTFLIGHT ? [] : []),
]
const UPDATES_CHANNEL = IS_TESTFLIGHT
? 'testflight'
: IS_PRODUCTION
@@ -92,7 +83,7 @@ module.exports = function (config) {
NSPhotoLibraryUsageDescription:
'Used for profile pictures, posts, and other kinds of content',
},
associatedDomains: ASSOCIATED_DOMAINS,
associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'],
splash: {
...SPLASH_CONFIG,
dark: DARK_SPLASH_CONFIG,
@@ -184,6 +175,7 @@ module.exports = function (config) {
checkAutomatically: 'NEVER',
channel: UPDATES_CHANNEL,
},
assetBundlePatterns: ['**/*'],
plugins: [
'expo-localization',
Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo',
@@ -191,7 +183,7 @@ module.exports = function (config) {
'expo-build-properties',
{
ios: {
deploymentTarget: '14.0',
deploymentTarget: '13.4',
newArchEnabled: false,
},
android: {
@@ -211,9 +203,6 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
'expo-video',
'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
@@ -246,10 +235,6 @@ module.exports = function (config) {
],
},
},
{
targetName: 'BlueskyClip',
bundleIdentifier: 'xyz.blueskyweb.app.AppClip',
},
],
},
},
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 285 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 284 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7ZM14.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7Z"/></svg>

Before

Width:  |  Height:  |  Size: 450 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.004 5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936A1 1 0 0 1 5.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234c.104-.085.21-.177.316-.276.846-.793 1.684-2.043 1.684-3.958V6h-5v6h2a1 1 0 0 1 1 1v4.234Zm6-12.234a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234V13a1 1 0 0 0-1-1h-2V6h5v7c0 1.915-.838 3.165-1.684 3.958-.106.1-.212.191-.316.276Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 775 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.003 5.999a2 2 0 0 1 2-1.999h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936a1 1 0 0 1-1.351-.935L5 14H4a2 2 0 0 1-2-2.001l.002-6Zm5 11.236L7 12.999A1 1 0 0 0 6 12H4l.003-6h5v7c0 1.915-.837 3.165-1.683 3.958-.106.1-.213.192-.317.277Zm6-11.235a2 2 0 0 1 2-2h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-1a2 2 0 0 1-2-2V6Zm7 0h-5v6h2a1 1 0 0 1 1 1v4.234c.105-.085.211-.177.317-.276.846-.793 1.684-2.043 1.684-3.958V6Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 821 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.4 9.493C4.14 10.28 4 11.124 4 12a8 8 0 1 0 10.899-7.459l-.953 3.81a1 1 0 0 1-.726.727l-3.444.866-.772 1.533a1 1 0 0 1-1.493.35L4.4 9.493Zm.883-1.84L7.756 9.51l.44-.874a1 1 0 0 1 .649-.52l3.306-.832.807-3.227a7.993 7.993 0 0 0-7.676 3.597ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8.43.162a1 1 0 0 1 .77-.29l1.89.121a1 1 0 0 1 .494.168l2.869 1.928a1 1 0 0 1 .336 1.277l-.973 1.946a1 1 0 0 1-.894.553h-2.92a1 1 0 0 1-.831-.445L9.225 14.5a1 1 0 0 1 .126-1.262l1.08-1.076Zm.915 1.913.177-.177 1.171.074 1.914 1.286-.303.607h-1.766l-1.194-1.79Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 714 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 514 B

After

Width:  |  Height:  |  Size: 513 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3c.552 0 1 .41 1 .917V13.5h3.583c.507 0 .917.448.917 1s-.41 1-.917 1H18v3.583c0 .507-.448.917-1 .917s-1-.41-1-.917V15.5h-3.583c-.507 0-.917-.448-.917-1s.41-1 .917-1H16V9.917C16 9.41 16.448 9 17 9Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 471 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 299 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FFC404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 848 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M8.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15C3.166 6.665 2.004 8.415 2.004 11v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5ZM19.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15-1.154 1.082-2.316 2.832-2.316 5.417v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5Z"/></svg>

Before

Width:  |  Height:  |  Size: 455 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.574 4.178a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.163 8.163 0 0 1 1.569-1.15 7.029 7.029 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588a5.99 5.99 0 0 0-.316.276C4.842 7.835 4.004 9.085 4.004 11v7h5v-6h-2a1 1 0 0 1-1-1V6.766Zm12.57-2.588a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.166 8.166 0 0 1 1.569-1.15 7.038 7.038 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588c-.105.085-.21.177-.316.276-.846.793-1.684 2.043-1.684 3.958v7h5v-6h-2a1 1 0 0 1-1-1V6.766Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 857 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H15a3 3 0 1 1 0-6c0-.824.332-1.571.87-2.113C14.739 12.32 13.435 12 12 12Zm6 2a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 412 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 508 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6.5 3a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v3.997a6.25 6.25 0 0 0 1.83 4.42l.377.376A1 1 0 0 1 20 12.5V15a1 1 0 0 1-1 1h-6v5a1 1 0 1 1-2 0v-5H5a1 1 0 0 1-1-1v-2.5a1 1 0 0 1 .293-.707l.376-.377A6.25 6.25 0 0 0 6.5 6.996V3.001Zm2 1v2.997a8.25 8.25 0 0 1-2.416 5.834L6 12.914V14h12v-1.086l-.084-.083A8.25 8.25 0 0 1 15.5 6.997V4h-7Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 465 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 580 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M16.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 0 1-1.414-1.414L17.586 7H5v4a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1h13.586l-1.293-1.293a1 1 0 0 1 0-1.414ZM21 13v5a1 1 0 0 1-1 1H6.414l1.293 1.293a1 1 0 1 1-1.414 1.414l-3-3a1 1 0 0 1 0-1.414l3-3a1 1 0 0 1 1.414 1.414L6.414 17H19v-4a1 1 0 1 1 2 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 446 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M16.793 2.293a1 1 0 0 1 1.414 0L20.5 4.586a2 2 0 0 1 0 2.828l-2.293 2.293a1 1 0 0 1-1.414-1.414L18.086 7H7a2 2 0 0 0-2 2v2a1 1 0 1 1-2 0V9a4 4 0 0 1 4-4h11.086l-1.293-1.293a1 1 0 0 1 0-1.414ZM20 12a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H5.914l1.293 1.293a1 1 0 1 1-1.414 1.414L3.5 19.414a2 2 0 0 1 0-2.828l2.293-2.293a1 1 0 0 1 1.414 1.414L5.914 17H17a2 2 0 0 0 2-2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 517 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 3a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2H8v2a1 1 0 1 1-2 0V8H4a1 1 0 0 1 0-2h2V4a1 1 0 0 1 1-1Zm6 4a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 1v4h4v-4H5Zm9.171-.829a1 1 0 0 1 1.415 0L17 15.585l1.414-1.414a1 1 0 1 1 1.414 1.414L18.414 17l1.414 1.414a1 1 0 0 1-1.414 1.414L17 18.414l-1.415 1.414a1 1 0 0 1-1.414-1.414l1.415-1.415-1.415-1.414a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 590 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.902 1.568a1 1 0 0 0-1.804 0L8.455 7.085l-6.085.799a1 1 0 0 0-.557 1.718l4.45 4.207-1.117 6.008a1 1 0 0 0 1.458 1.063L12 17.962l5.396 2.918a1 1 0 0 0 1.459-1.063l-1.117-6.008 4.45-4.207a1 1 0 0 0-.558-1.718l-6.085-.8-2.643-5.516Z"/></svg>

Before

Width:  |  Height:  |  Size: 335 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 1a1 1 0 0 1 .902.568l2.643 5.517 6.085.799a1 1 0 0 1 .557 1.718l-4.45 4.207 1.118 6.008a1 1 0 0 1-1.46 1.063L12 17.962 6.604 20.88a1 1 0 0 1-1.459-1.063l1.117-6.008-4.45-4.207a1 1 0 0 1 .558-1.718l6.085-.8 2.643-5.516A1 1 0 0 1 12 1Zm0 3.315-1.975 4.123a1 1 0 0 1-.772.56l-4.538.595 3.317 3.137a1 1 0 0 1 .296.91l-.834 4.485 4.03-2.179a1 1 0 0 1 .952 0l4.03 2.179-.834-4.485a1 1 0 0 1 .296-.91l3.317-3.137-4.538-.596a1 1 0 0 1-.772-.56L12 4.316Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 592 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z" clip-rule="evenodd"/><path fill="#000" fill-rule="evenodd" d="M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 687 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a49.7 49.7 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232c.246-.005.493-.007.741-.007 21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.57 38.57 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0A7AFF"/><stop offset="1" stop-color="#59B9FF"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 370 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

+4 -7
View File
@@ -8,6 +8,9 @@ module.exports = function (api) {
{
lazyImports: true,
native: {
// We should be able to remove this after upgrading Expo
// to a version that includes https://github.com/expo/expo/pull/24672.
unstable_transformProfile: 'hermes-stable',
// Disable ESM -> CJS compilation because Metro takes care of it.
// However, we need it in Jest tests since those run without Metro.
disableImportExportTransform: !isTestEnv,
@@ -16,13 +19,6 @@ module.exports = function (api) {
],
],
plugins: [
'macros',
[
'babel-plugin-react-compiler',
{
runtimeModule: 'react-compiler-runtime',
},
],
[
'module:react-native-dotenv',
{
@@ -50,6 +46,7 @@ module.exports = function (api) {
},
},
],
'macros',
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
],
env: {
+1 -1
View File
@@ -15,6 +15,6 @@
"parserOptions": {
"sourceType": "module",
"ecmaVersion": "latest",
"project": "./bskyembed/tsconfig.json"
"project": "./tsconfig.json"
}
}
+2 -3
View File
@@ -193,7 +193,7 @@ export function Embed({
function Info({children}: {children: ComponentChildren}) {
return (
<div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
<img src={infoIcon} className="w-4 h-4 shrink-0 mt-0.5" />
<img src={infoIcon as string} className="w-4 h-4 shrink-0 mt-0.5" />
<p className="text-sm text-textLight">{children}</p>
</div>
)
@@ -293,8 +293,7 @@ function ExternalEmbed({
return (
<Link
href={content.external.uri}
className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch"
disableTracking>
className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
{content.external.thumb && (
<img
src={content.external.thumb}
+3 -5
View File
@@ -3,12 +3,10 @@ import {h} from 'preact'
export function Link({
href,
className,
disableTracking,
...props
}: {
href: string
className?: string
disableTracking?: boolean
} & h.JSX.HTMLAttributes<HTMLAnchorElement>) {
const searchParam = new URLSearchParams(window.location.search)
const ref_url = searchParam.get('ref_url')
@@ -21,9 +19,9 @@ export function Link({
return (
<a
href={`${href.startsWith('http') ? href : `https://bsky.app${href}`}${
disableTracking ? '' : `?${newSearchParam.toString()}`
}`}
href={`${
href.startsWith('http') ? href : `https://bsky.app${href}`
}?${newSearchParam.toString()}`}
target="_blank"
rel="noopener noreferrer nofollow"
onClick={evt => evt.stopPropagation()}
+9 -24
View File
@@ -1,9 +1,4 @@
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyRichtextFacet,
RichText,
} from '@atproto/api'
import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
import {h} from 'preact'
import replyIcon from '../../assets/bubble_filled_stroke2_corner2_rounded.svg'
@@ -61,7 +56,7 @@ export function Post({thread}: Props) {
<Link
href={href}
className="transition-transform hover:scale-110 shrink-0 self-start">
<img src={logo} className="h-8" />
<img src={logo as string} className="h-8" />
</Link>
</div>
<PostContent record={record} />
@@ -76,7 +71,7 @@ export function Post({thread}: Props) {
<div className="border-t w-full pt-2.5 flex items-center gap-5 text-sm cursor-pointer">
{!!post.likeCount && (
<div className="flex items-center gap-2 cursor-pointer">
<img src={likeIcon} className="w-5 h-5" />
<img src={likeIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.likeCount}
</p>
@@ -84,14 +79,14 @@ export function Post({thread}: Props) {
)}
{!!post.repostCount && (
<div className="flex items-center gap-2 cursor-pointer">
<img src={repostIcon} className="w-5 h-5" />
<img src={repostIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.repostCount}
</p>
</div>
)}
<div className="flex items-center gap-2 cursor-pointer">
<img src={replyIcon} className="w-5 h-5" />
<img src={replyIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">Reply</p>
</div>
<div className="flex-1" />
@@ -123,23 +118,16 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
let counter = 0
for (const segment of rt.segments()) {
if (
segment.link &&
AppBskyRichtextFacet.validateLink(segment.link).success
) {
if (segment.isLink() && segment.link) {
richText.push(
<Link
key={counter}
href={segment.link.uri}
className="text-blue-400 hover:underline"
disableTracking={!segment.link.uri.startsWith('https://bsky.app')}>
className="text-blue-400 hover:underline">
{segment.text}
</Link>,
)
} else if (
segment.mention &&
AppBskyRichtextFacet.validateMention(segment.mention).success
) {
} else if (segment.isMention() && segment.mention) {
richText.push(
<Link
key={counter}
@@ -148,10 +136,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
{segment.text}
</Link>,
)
} else if (
segment.tag &&
AppBskyRichtextFacet.validateTag(segment.tag).success
) {
} else if (segment.isTag() && segment.tag) {
richText.push(
<Link
key={counter}
+2 -2
View File
@@ -112,7 +112,7 @@ function LandingPage() {
<Link
href="https://bsky.social/about"
className="transition-transform hover:scale-110">
<img src={logo} className="h-10" />
<img src={logo as string} className="h-10" />
</Link>
<h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1>
@@ -125,7 +125,7 @@ function LandingPage() {
placeholder={DEFAULT_POST}
/>
<img src={arrowBottom} className="w-6" />
<img src={arrowBottom as string} className="w-6" />
{loading ? (
<Skeleton />
+2 -2
View File
@@ -52,7 +52,7 @@ function PwiOptOut({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
<Link
href={href}
className="transition-transform hover:scale-110 absolute top-4 right-4">
<img src={logo} className="h-6" />
<img src={logo as string} className="h-6" />
</Link>
<div className="w-full py-12 gap-4 flex flex-col items-center">
<p className="max-w-80 text-center w-full text-textLight">
@@ -75,7 +75,7 @@ function ErrorMessage() {
<Link
href="https://bsky.app/"
className="transition-transform hover:scale-110 absolute top-4 right-4">
<img src={logo} className="h-6" />
<img src={logo as string} className="h-6" />
</Link>
<p className="my-16 text-center w-full text-textLight">
Post not found, it may have been deleted.
-26
View File
@@ -1,26 +0,0 @@
{
"name": "bskylink",
"version": "0.0.0",
"type": "module",
"main": "index.ts",
"scripts": {
"test": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
"build": "tsc"
},
"dependencies": {
"@atproto/common": "^0.4.0",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.19.2",
"http-terminator": "^3.2.0",
"kysely": "^0.27.3",
"pg": "^8.12.0",
"pino": "^9.2.0",
"uint8arrays": "^5.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/pg": "^8.11.6",
"typescript": "^5.4.5"
}
}
-24
View File
@@ -1,24 +0,0 @@
import {Database, envToCfg, httpLogger, LinkService, readEnv} from './index.js'
async function main() {
const env = readEnv()
const cfg = envToCfg(env)
if (cfg.db.migrationUrl) {
const migrateDb = Database.postgres({
url: cfg.db.migrationUrl,
schema: cfg.db.schema,
})
await migrateDb.migrateToLatestOrThrow()
await migrateDb.close()
}
const link = await LinkService.create(cfg)
await link.start()
httpLogger.info('link service is running')
process.on('SIGTERM', async () => {
httpLogger.info('link service is stopping')
await link.destroy()
httpLogger.info('link service is stopped')
})
}
main()
-82
View File
@@ -1,82 +0,0 @@
import {envInt, envList, envStr} from '@atproto/common'
export type Config = {
service: ServiceConfig
db: DbConfig
}
export type ServiceConfig = {
port: number
version?: string
hostnames: string[]
appHostname: string
}
export type DbConfig = {
url: string
migrationUrl?: string
pool: DbPoolConfig
schema?: string
}
export type DbPoolConfig = {
size: number
maxUses: number
idleTimeoutMs: number
}
export type Environment = {
port?: number
version?: string
hostnames: string[]
appHostname?: string
dbPostgresUrl?: string
dbPostgresMigrationUrl?: string
dbPostgresSchema?: string
dbPostgresPoolSize?: number
dbPostgresPoolMaxUses?: number
dbPostgresPoolIdleTimeoutMs?: number
}
export const readEnv = (): Environment => {
return {
port: envInt('LINK_PORT'),
version: envStr('LINK_VERSION'),
hostnames: envList('LINK_HOSTNAMES'),
appHostname: envStr('LINK_APP_HOSTNAME'),
dbPostgresUrl: envStr('LINK_DB_POSTGRES_URL'),
dbPostgresMigrationUrl: envStr('LINK_DB_POSTGRES_MIGRATION_URL'),
dbPostgresSchema: envStr('LINK_DB_POSTGRES_SCHEMA'),
dbPostgresPoolSize: envInt('LINK_DB_POSTGRES_POOL_SIZE'),
dbPostgresPoolMaxUses: envInt('LINK_DB_POSTGRES_POOL_MAX_USES'),
dbPostgresPoolIdleTimeoutMs: envInt(
'LINK_DB_POSTGRES_POOL_IDLE_TIMEOUT_MS',
),
}
}
export const envToCfg = (env: Environment): Config => {
const serviceCfg: ServiceConfig = {
port: env.port ?? 3000,
version: env.version,
hostnames: env.hostnames,
appHostname: env.appHostname || 'bsky.app',
}
if (!env.dbPostgresUrl) {
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
}
const dbCfg: DbConfig = {
url: env.dbPostgresUrl,
migrationUrl: env.dbPostgresMigrationUrl,
schema: env.dbPostgresSchema,
pool: {
idleTimeoutMs: env.dbPostgresPoolIdleTimeoutMs ?? 10000,
maxUses: env.dbPostgresPoolMaxUses ?? Infinity,
size: env.dbPostgresPoolSize ?? 10,
},
}
return {
service: serviceCfg,
db: dbCfg,
}
}
-33
View File
@@ -1,33 +0,0 @@
import {Config} from './config.js'
import Database from './db/index.js'
export type AppContextOptions = {
cfg: Config
db: Database
}
export class AppContext {
cfg: Config
db: Database
abortController = new AbortController()
constructor(private opts: AppContextOptions) {
this.cfg = this.opts.cfg
this.db = this.opts.db
}
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
const db = Database.postgres({
url: cfg.db.url,
schema: cfg.db.schema,
poolSize: cfg.db.pool.size,
poolMaxUses: cfg.db.pool.maxUses,
poolIdleTimeoutMs: cfg.db.pool.idleTimeoutMs,
})
return new AppContext({
cfg,
db,
...overrides,
})
}
}
-174
View File
@@ -1,174 +0,0 @@
import assert from 'assert'
import {
Kysely,
KyselyPlugin,
Migrator,
PluginTransformQueryArgs,
PluginTransformResultArgs,
PostgresDialect,
QueryResult,
RootOperationNode,
UnknownRow,
} from 'kysely'
import {default as Pg} from 'pg'
import {dbLogger as log} from '../logger.js'
import {default as migrations} from './migrations/index.js'
import {DbMigrationProvider} from './migrations/provider.js'
import {DbSchema} from './schema.js'
export class Database {
migrator: Migrator
destroyed = false
constructor(public db: Kysely<DbSchema>, public cfg: PgConfig) {
this.migrator = new Migrator({
db,
migrationTableSchema: cfg.schema,
provider: new DbMigrationProvider(migrations),
})
}
static postgres(opts: PgOptions): Database {
const {schema, url, txLockNonce} = opts
const pool =
opts.pool ??
new Pg.Pool({
connectionString: url,
max: opts.poolSize,
maxUses: opts.poolMaxUses,
idleTimeoutMillis: opts.poolIdleTimeoutMs,
})
// Select count(*) and other pg bigints as js integer
Pg.types.setTypeParser(Pg.types.builtins.INT8, n => parseInt(n, 10))
// Setup schema usage, primarily for test parallelism (each test suite runs in its own pg schema)
if (schema && !/^[a-z_]+$/i.test(schema)) {
throw new Error(`Postgres schema must only contain [A-Za-z_]: ${schema}`)
}
pool.on('error', onPoolError)
const db = new Kysely<DbSchema>({
dialect: new PostgresDialect({pool}),
})
return new Database(db, {
pool,
schema,
url,
txLockNonce,
})
}
async transaction<T>(fn: (db: Database) => Promise<T>): Promise<T> {
const leakyTxPlugin = new LeakyTxPlugin()
return this.db
.withPlugin(leakyTxPlugin)
.transaction()
.execute(txn => {
const dbTxn = new Database(txn, this.cfg)
return fn(dbTxn)
.catch(async err => {
leakyTxPlugin.endTx()
// ensure that all in-flight queries are flushed & the connection is open
await dbTxn.db.getExecutor().provideConnection(async () => {})
throw err
})
.finally(() => leakyTxPlugin.endTx())
})
}
get schema(): string | undefined {
return this.cfg.schema
}
get isTransaction() {
return this.db.isTransaction
}
assertTransaction() {
assert(this.isTransaction, 'Transaction required')
}
assertNotTransaction() {
assert(!this.isTransaction, 'Cannot be in a transaction')
}
async close(): Promise<void> {
if (this.destroyed) return
await this.db.destroy()
this.destroyed = true
}
async migrateToOrThrow(migration: string) {
if (this.schema) {
await this.db.schema.createSchema(this.schema).ifNotExists().execute()
}
const {error, results} = await this.migrator.migrateTo(migration)
if (error) {
throw error
}
if (!results) {
throw new Error('An unknown failure occurred while migrating')
}
return results
}
async migrateToLatestOrThrow() {
if (this.schema) {
await this.db.schema.createSchema(this.schema).ifNotExists().execute()
}
const {error, results} = await this.migrator.migrateToLatest()
if (error) {
throw error
}
if (!results) {
throw new Error('An unknown failure occurred while migrating')
}
return results
}
}
export default Database
export type PgConfig = {
pool: Pg.Pool
url: string
schema?: string
txLockNonce?: string
}
type PgOptions = {
url: string
pool?: Pg.Pool
schema?: string
poolSize?: number
poolMaxUses?: number
poolIdleTimeoutMs?: number
txLockNonce?: string
}
class LeakyTxPlugin implements KyselyPlugin {
private txOver = false
endTx() {
this.txOver = true
}
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
if (this.txOver) {
throw new Error('tx already failed')
}
return args.node
}
async transformResult(
args: PluginTransformResultArgs,
): Promise<QueryResult<UnknownRow>> {
return args.result
}
}
const onPoolError = (err: Error) => log.error({err}, 'db pool error')
-15
View File
@@ -1,15 +0,0 @@
import {Kysely} from 'kysely'
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('link')
.addColumn('id', 'varchar', col => col.primaryKey())
.addColumn('type', 'smallint', col => col.notNull()) // integer enum: 1->starterpack
.addColumn('path', 'varchar', col => col.notNull())
.addUniqueConstraint('link_path_unique', ['path'])
.execute()
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('link').execute()
}
-5
View File
@@ -1,5 +0,0 @@
import * as init from './001-init.js'
export default {
'001': init,
}
-8
View File
@@ -1,8 +0,0 @@
import {Migration, MigrationProvider} from 'kysely'
export class DbMigrationProvider implements MigrationProvider {
constructor(private migrations: Record<string, Migration>) {}
async getMigrations(): Promise<Record<string, Migration>> {
return this.migrations
}
}
-17
View File
@@ -1,17 +0,0 @@
import {Selectable} from 'kysely'
export type DbSchema = {
link: Link
}
export interface Link {
id: string
type: LinkType
path: string
}
export enum LinkType {
StarterPack = 1,
}
export type LinkEntry = Selectable<Link>
-45
View File
@@ -1,45 +0,0 @@
import events from 'node:events'
import http from 'node:http'
import cors from 'cors'
import express from 'express'
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
import {Config} from './config.js'
import {AppContext} from './context.js'
import {default as routes, errorHandler} from './routes/index.js'
export * from './config.js'
export * from './db/index.js'
export * from './logger.js'
export class LinkService {
public server?: http.Server
private terminator?: HttpTerminator
constructor(public app: express.Application, public ctx: AppContext) {}
static async create(cfg: Config): Promise<LinkService> {
let app = express()
app.use(cors())
const ctx = await AppContext.fromConfig(cfg)
app = routes(ctx, app)
app.use(errorHandler)
return new LinkService(app, ctx)
}
async start() {
this.server = this.app.listen(this.ctx.cfg.service.port)
this.server.keepAliveTimeout = 90000
this.terminator = createHttpTerminator({server: this.server})
await events.once(this.server, 'listening')
}
async destroy() {
this.ctx.abortController.abort()
await this.terminator?.terminate()
await this.ctx.db.close()
}
}
-4
View File
@@ -1,4 +0,0 @@
import {subsystemLogger} from '@atproto/common'
export const httpLogger = subsystemLogger('bskylink')
export const dbLogger = subsystemLogger('bskylink:db')
-111
View File
@@ -1,111 +0,0 @@
import assert from 'node:assert'
import bodyParser from 'body-parser'
import {Express, Request} from 'express'
import {AppContext} from '../context.js'
import {LinkType} from '../db/schema.js'
import {randomId} from '../util.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.post(
'/link',
bodyParser.json(),
handler(async (req, res) => {
let path: string
if (typeof req.body?.path === 'string') {
path = req.body.path
} else {
return res.status(400).json({
error: 'InvalidPath',
message: '"path" parameter is missing or not a string',
})
}
if (!path.startsWith('/')) {
return res.status(400).json({
error: 'InvalidPath',
message:
'"path" parameter must be formatted as a path, starting with a "/"',
})
}
const parts = getPathParts(path)
if (parts.length === 3 && parts[0] === 'start') {
// link pattern: /start/{did}/{rkey}
if (!parts[1].startsWith('did:')) {
// enforce strong links
return res.status(400).json({
error: 'InvalidPath',
message:
'"path" parameter for starter pack must contain the actor\'s DID',
})
}
const id = await ensureLink(ctx, LinkType.StarterPack, parts)
return res.json({url: getUrl(ctx, req, id)})
}
return res.status(400).json({
error: 'InvalidPath',
message: '"path" parameter does not have a known format',
})
}),
)
}
const ensureLink = async (ctx: AppContext, type: LinkType, parts: string[]) => {
const normalizedPath = normalizedPathFromParts(parts)
const created = await ctx.db.db
.insertInto('link')
.values({
id: randomId(),
type,
path: normalizedPath,
})
.onConflict(oc => oc.column('path').doNothing())
.returningAll()
.executeTakeFirst()
if (created) {
return created.id
}
const found = await ctx.db.db
.selectFrom('link')
.selectAll()
.where('path', '=', normalizedPath)
.executeTakeFirstOrThrow()
return found.id
}
const getUrl = (ctx: AppContext, req: Request, id: string) => {
if (!ctx.cfg.service.hostnames.length) {
assert(req.headers.host, 'request must be made with host header')
const baseUrl =
req.protocol === 'http' && req.headers.host.startsWith('localhost:')
? `http://${req.headers.host}`
: `https://${req.headers.host}`
return `${baseUrl}/${id}`
}
const baseUrl = ctx.cfg.service.hostnames.includes(req.headers.host)
? `https://${req.headers.host}`
: `https://${ctx.cfg.service.hostnames[0]}`
return `${baseUrl}/${id}`
}
const normalizedPathFromParts = (parts: string[]): string => {
return (
'/' +
parts
.map(encodeURIComponent)
.map(part => part.replaceAll('%3A', ':')) // preserve colons
.join('/')
)
}
const getPathParts = (path: string): string[] => {
if (path === '/') return []
if (path.endsWith('/')) {
path = path.slice(0, -1) // ignore trailing slash
}
return path
.slice(1) // remove leading slash
.split('/')
.map(decodeURIComponent)
}
-20
View File
@@ -1,20 +0,0 @@
import {Express} from 'express'
import {sql} from 'kysely'
import {AppContext} from '../context.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/_health',
handler(async (_req, res) => {
const {version} = ctx.cfg.service
try {
await sql`select 1`.execute(ctx.db.db)
return res.send({version})
} catch (err) {
return res.status(503).send({version, error: 'Service Unavailable'})
}
}),
)
}
-17
View File
@@ -1,17 +0,0 @@
import {Express} from 'express'
import {AppContext} from '../context.js'
import {default as create} from './create.js'
import {default as health} from './health.js'
import {default as redirect} from './redirect.js'
import {default as siteAssociation} from './siteAssociation.js'
export * from './util.js'
export default function (ctx: AppContext, app: Express) {
app = health(ctx, app) // GET /_health
app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
app = create(ctx, app) // POST /link
app = redirect(ctx, app) // GET /:linkId (should go last due to permissive matching)
return app
}
-54
View File
@@ -1,54 +0,0 @@
import assert from 'node:assert'
import {DAY, SECOND} from '@atproto/common'
import {Express} from 'express'
import {AppContext} from '../context.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/:linkId',
handler(async (req, res) => {
const linkId = req.params.linkId
const contentType = req.accepts(['html', 'json'])
assert(
typeof linkId === 'string',
'express guarantees id parameter is a string',
)
const found = await ctx.db.db
.selectFrom('link')
.selectAll()
.where('id', '=', linkId)
.executeTakeFirst()
if (!found) {
// potentially broken or mistyped link
res.setHeader('Cache-Control', 'no-store')
if (contentType === 'json') {
return res
.status(404)
.json({
error: 'NotFound',
message: 'Link not found',
})
.end()
}
// send the user to the app
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
return res.status(302).end()
}
// build url from original url in order to preserve query params
const url = new URL(
req.originalUrl,
`https://${ctx.cfg.service.appHostname}`,
)
url.pathname = found.path
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
if (contentType === 'json') {
return res.json({url: url.href}).end()
}
res.setHeader('Location', url.href)
return res.status(301).end()
}),
)
}
-22
View File
@@ -1,22 +0,0 @@
import {Express} from 'express'
import {AppContext} from '../context.js'
export default function (ctx: AppContext, app: Express) {
return app.get('/.well-known/apple-app-site-association', (req, res) => {
res.json({
applinks: {
apps: [],
details: [
{
appID: 'B3LX46C5HS.xyz.blueskyweb.app',
paths: ['*'],
},
],
},
appclips: {
apps: ['B3LX46C5HS.xyz.blueskyweb.app.AppClip'],
},
})
})
}
-23
View File
@@ -1,23 +0,0 @@
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
import {httpLogger} from '../logger.js'
export type Handler = (req: Request, res: Response) => Awaited<void>
export const handler = (runHandler: Handler): RequestHandler => {
return async (req, res, next) => {
try {
await runHandler(req, res)
} catch (err) {
next(err)
}
}
}
export const errorHandler: ErrorRequestHandler = (err, _req, res, next) => {
httpLogger.error({err}, 'request error')
if (res.headersSent) {
return next(err)
}
return res.status(500).end('server error')
}
-8
View File
@@ -1,8 +0,0 @@
import {randomBytes} from 'node:crypto'
import {toString} from 'uint8arrays'
// 40bit random id of 5-7 characters
export const randomId = () => {
return toString(randomBytes(5), 'base58btc')
}
-123
View File
@@ -1,123 +0,0 @@
import assert from 'node:assert'
import {AddressInfo} from 'node:net'
import {after, before, describe, it} from 'node:test'
import {Database, envToCfg, LinkService, readEnv} from '../src/index.js'
describe('link service', async () => {
let linkService: LinkService
let baseUrl: string
before(async () => {
const env = readEnv()
const cfg = envToCfg({
...env,
hostnames: ['test.bsky.link'],
appHostname: 'test.bsky.app',
dbPostgresSchema: 'link_test',
dbPostgresUrl: process.env.DB_POSTGRES_URL,
})
const migrateDb = Database.postgres({
url: cfg.db.url,
schema: cfg.db.schema,
})
await migrateDb.migrateToLatestOrThrow()
await migrateDb.close()
linkService = await LinkService.create(cfg)
await linkService.start()
const {port} = linkService.server?.address() as AddressInfo
baseUrl = `http://localhost:${port}`
})
after(async () => {
await linkService?.destroy()
})
it('creates a starter pack link', async () => {
const link = await getLink('/start/did:example:alice/xxx')
const url = new URL(link)
assert.strictEqual(url.origin, 'https://test.bsky.link')
assert.match(url.pathname, /^\/[a-z0-9]+$/i)
})
it('normalizes input paths and provides same link each time.', async () => {
const link1 = await getLink('/start/did%3Aexample%3Abob/yyy')
const link2 = await getLink('/start/did:example:bob/yyy/')
assert.strictEqual(link1, link2)
})
it('serves permanent redirect, preserving query params.', async () => {
const link = await getLink('/start/did:example:carol/zzz/')
const [status, location] = await getRedirect(`${link}?a=b`)
assert.strictEqual(status, 301)
const locationUrl = new URL(location)
assert.strictEqual(
locationUrl.pathname + locationUrl.search,
'/start/did:example:carol/zzz?a=b',
)
})
it('returns json object with url when requested', async () => {
const link = await getLink('/start/did:example:carol/zzz/')
const [status, json] = await getJsonRedirect(link)
assert.strictEqual(status, 200)
assert(json.url)
const url = new URL(json.url)
assert.strictEqual(url.pathname, '/start/did:example:carol/zzz')
})
it('returns 404 for unknown link when requesting json', async () => {
const [status, json] = await getJsonRedirect(
'https://test.bsky.link/unknown',
)
assert(json.error)
assert(json.message)
assert.strictEqual(status, 404)
assert.strictEqual(json.error, 'NotFound')
assert.strictEqual(json.message, 'Link not found')
})
async function getRedirect(link: string): Promise<[number, string]> {
const url = new URL(link)
const base = new URL(baseUrl)
url.protocol = base.protocol
url.host = base.host
const res = await fetch(url, {redirect: 'manual'})
await res.arrayBuffer() // drain
assert(
res.status === 301 || res.status === 303,
'response was not a redirect',
)
return [res.status, res.headers.get('location') ?? '']
}
async function getJsonRedirect(
link: string,
): Promise<[number, {url?: string; error?: string; message?: string}]> {
const url = new URL(link)
const base = new URL(baseUrl)
url.protocol = base.protocol
url.host = base.host
const res = await fetch(url, {
redirect: 'manual',
headers: {accept: 'application/json,text/html'},
})
assert(
res.headers.get('content-type')?.startsWith('application/json'),
'content type was not json',
)
const json = await res.json()
return [res.status, json]
}
async function getLink(path: string): Promise<string> {
const res = await fetch(new URL('/link', baseUrl), {
method: 'post',
headers: {'content-type': 'application/json'},
body: JSON.stringify({path}),
})
assert.strictEqual(res.status, 200)
const payload = await res.json()
assert(typeof payload.url === 'string')
return payload.url
}
})
-157
View File
@@ -1,157 +0,0 @@
#!/usr/bin/env sh
# Exit if any command fails
set -e
get_container_id() {
local compose_file=$1
local service=$2
if [ -z "${compose_file}" ] || [ -z "${service}" ]; then
echo "usage: get_container_id <compose_file> <service>"
exit 1
fi
# first line of jq normalizes for docker compose breaking change, see docker/compose#10958
docker compose --file $compose_file ps --format json --status running \
| jq -sc '.[] | if type=="array" then .[] else . end' | jq -s \
| jq -r '.[]? | select(.Service == "'${service}'") | .ID'
}
# Exports all environment variables
export_env() {
export_pg_env
}
# Exports postgres environment variables
export_pg_env() {
# Based on creds in compose.yaml
export PGPORT=5433
export PGHOST=localhost
export PGUSER=pg
export PGPASSWORD=password
export PGDATABASE=postgres
export DB_POSTGRES_URL="postgresql://pg:password@127.0.0.1:5433/postgres"
}
pg_clear() {
local pg_uri=$1
for schema_name in `psql "${pg_uri}" -c "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_%' AND schema_name NOT LIKE 'information_schema';" -t`; do
psql "${pg_uri}" -c "DROP SCHEMA \"${schema_name}\" CASCADE;"
done
}
pg_init() {
local pg_uri=$1
psql "${pg_uri}" -c "CREATE SCHEMA IF NOT EXISTS \"public\";"
}
main_native() {
local services=${SERVICES}
local postgres_url_env_var=`[[ $services == *"db_test"* ]] && echo "DB_TEST_POSTGRES_URL" || echo "DB_POSTGRES_URL"`
postgres_url="${!postgres_url_env_var}"
if [ -n "${postgres_url}" ]; then
echo "Using ${postgres_url_env_var} (${postgres_url}) to connect to postgres."
pg_init "${postgres_url}"
else
echo "Postgres connection string missing did you set ${postgres_url_env_var}?"
exit 1
fi
cleanup() {
local services=$@
if [ -n "${postgres_url}" ] && [[ $services == *"db_test"* ]]; then
pg_clear "${postgres_url}" &> /dev/null
fi
}
# trap SIGINT and performs cleanup
trap "on_sigint ${services}" INT
on_sigint() {
cleanup $@
exit $?
}
# Run the arguments as a command
DB_POSTGRES_URL="${postgres_url}" \
"$@"
code=$?
cleanup ${services}
exit ${code}
}
main_docker() {
# Expect a SERVICES env var to be set with the docker service names
local services=${SERVICES}
dir=$(dirname $0)
compose_file="${dir}/docker-compose.yaml"
# whether this particular script started the container(s)
started_container=false
# performs cleanup as necessary, i.e. taking down containers
# if this script started them
cleanup() {
local services=$@
echo # newline
if $started_container; then
docker compose --file $compose_file rm --force --stop --volumes ${services}
fi
}
# trap SIGINT and performs cleanup
trap "on_sigint ${services}" INT
on_sigint() {
cleanup $@
exit $?
}
# check if all services are running already
not_running=false
for service in $services; do
container_id=$(get_container_id $compose_file $service)
if [ -z $container_id ]; then
not_running=true
break
fi
done
# if any are missing, recreate all services
if $not_running; then
started_container=true
docker compose --file $compose_file up --wait --force-recreate ${services}
else
echo "all services ${services} are already running"
fi
# do not exit when following commands fail, so we can intercept exit code & tear down docker
set +e
# setup environment variables and run args
export_env
"$@"
# save return code for later
code=$?
# performs cleanup as necessary
cleanup ${services}
exit ${code}
}
# Main entry point
main() {
if ! docker ps >/dev/null 2>&1; then
echo "Docker unavailable. Running on host."
main_native $@
else
main_docker $@
fi
}
-27
View File
@@ -1,27 +0,0 @@
version: '3.8'
services:
# An ephermerally-stored postgres database for single-use test runs
db_test: &db_test
image: postgres:14.11-alpine
environment:
- POSTGRES_USER=pg
- POSTGRES_PASSWORD=password
ports:
- '5433:5432'
# Healthcheck ensures db is queryable when `docker-compose up --wait` completes
healthcheck:
test: 'pg_isready -U pg'
interval: 500ms
timeout: 10s
retries: 20
# A persistently-stored postgres database
db:
<<: *db_test
ports:
- '5432:5432'
healthcheck:
disable: true
volumes:
- link_db:/var/lib/postgresql/data
volumes:
link_db:
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env sh
# Example usage:
# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
dir=$(dirname $0)
. ${dir}/_common.sh
SERVICES="db_test" main "$@"
-10
View File
@@ -1,10 +0,0 @@
{
"compilerOptions": {
"module": "NodeNext",
"esModuleInterop": true,
"moduleResolution": "NodeNext",
"outDir": "dist",
"lib": ["ES2021.String"]
},
"include": ["./src/index.ts", "./src/bin.ts"]
}
-1027
View File
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
{
"name": "bskyogcard",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"scripts": {
"start": "node --loader ts-node/esm ./src/bin.ts",
"dev": "node --watch-path ./src --loader ts-node/esm ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/",
"install-fonts": "node --loader ts-node/esm scripts/install-fonts.ts"
},
"dependencies": {
"@atproto/api": "0.12.19-next.0",
"@atproto/common": "^0.4.0",
"@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2",
"http-terminator": "^3.2.0",
"pino": "^9.2.0",
"react": "^18.3.1",
"satori": "^0.10.13",
"twemoji": "^14.0.2"
},
"devDependencies": {
"@types/node": "^20.14.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
}
}
-40
View File
@@ -1,40 +0,0 @@
import {writeFile} from 'node:fs/promises'
import * as path from 'node:path'
import {fileURLToPath} from 'node:url'
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
const FONTS = [
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@5.0/japanese-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@5.0/chinese-traditional-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0/chinese-simplified-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hk@5.0/chinese-hongkong-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-kr@5.0/korean-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-thai@5.0/thai-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-arabic@5.0/arabic-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hebrew@5.0/hebrew-700-normal.ttf',
]
async function main() {
await Promise.all(
FONTS.map(async urlStr => {
const url = new URL(urlStr)
const res = await fetch(url)
const font = await res.arrayBuffer()
const filename = url.pathname
.split('/')
.slice(-2)
.join('/')
.replace(/@[\d.]+\//, '-')
if (!res.ok) {
throw new Error(`HTTP ${res.status}: fetching failed for ${filename}`)
}
await writeFile(
path.join(__DIRNAME, '..', 'src', 'assets', 'fonts', filename),
Buffer.from(font),
)
}),
)
}
main()
Binary file not shown.
-48
View File
@@ -1,48 +0,0 @@
import cluster, {Worker} from 'node:cluster'
import {envInt} from '@atproto/common'
import {CardService, envToCfg, httpLogger, readEnv} from './index.js'
async function main() {
const env = readEnv()
const cfg = envToCfg(env)
const card = await CardService.create(cfg)
await card.start()
httpLogger.info('card service is running')
process.on('SIGTERM', async () => {
httpLogger.info('card service is stopping')
await card.destroy()
httpLogger.info('card service is stopped')
if (cluster.isWorker) process.exit(0)
})
}
const workerCount = envInt('CARD_CLUSTER_WORKER_COUNT')
if (workerCount) {
if (cluster.isPrimary) {
httpLogger.info(`primary ${process.pid} is running`)
const workers = new Set<Worker>()
for (let i = 0; i < workerCount; ++i) {
workers.add(cluster.fork())
}
let teardown = false
cluster.on('exit', worker => {
workers.delete(worker)
if (!teardown) {
workers.add(cluster.fork()) // restart on crash
}
})
process.on('SIGTERM', () => {
teardown = true
httpLogger.info('disconnecting workers')
workers.forEach(w => w.kill('SIGTERM'))
})
} else {
httpLogger.info(`worker ${process.pid} is running`)
main()
}
} else {
main() // non-clustering
}
-16
View File
@@ -1,16 +0,0 @@
import React from 'react'
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 568 501"
{...props}>
<path
fill="currentColor"
d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"
/>
</svg>
)
}
-10
View File
@@ -1,10 +0,0 @@
import React from 'react'
export function Img(
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
) {
const {src, ...others} = props
return (
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
)
}
-152
View File
@@ -1,152 +0,0 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text */
import React from 'react'
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {Butterfly} from './Butterfly.js'
import {Img} from './Img.js'
export const STARTERPACK_HEIGHT = 630
export const STARTERPACK_WIDTH = 1200
export const TILE_SIZE = STARTERPACK_HEIGHT / 3
const GRADIENT_TOP = '#0A7AFF'
const GRADIENT_BOTTOM = '#59B9FF'
const IMAGE_STROKE = '#359CFF'
export function StarterPack(props: {
starterPack: AppBskyGraphDefs.StarterPackView
images: Map<string, Buffer>
}) {
const {starterPack, images} = props
const record = AppBskyGraphStarterpack.isRecord(starterPack.record)
? starterPack.record
: null
const imagesArray = [...images.values()]
const imageOfCreator = images.get(starterPack.creator.did)
const imagesExceptCreator = [...images.entries()]
.filter(([did]) => did !== starterPack.creator.did)
.map(([, image]) => image)
const imagesAcross: Buffer[] = []
if (imageOfCreator) {
if (imagesExceptCreator.length >= 6) {
imagesAcross.push(...imagesExceptCreator.slice(0, 3))
imagesAcross.push(imageOfCreator)
imagesAcross.push(...imagesExceptCreator.slice(3, 6))
} else {
const firstHalf = Math.floor(imagesExceptCreator.length / 2)
imagesAcross.push(...imagesExceptCreator.slice(0, firstHalf))
imagesAcross.push(imageOfCreator)
imagesAcross.push(
...imagesExceptCreator.slice(firstHalf, imagesExceptCreator.length),
)
}
} else {
imagesAcross.push(...imagesExceptCreator.slice(0, 7))
}
const isLongTitle = record ? record.name.length > 30 : false
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
width: STARTERPACK_WIDTH,
height: STARTERPACK_HEIGHT,
backgroundColor: 'black',
color: 'white',
fontFamily: 'Inter',
}}>
{/* image tiles */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'stretch',
width: TILE_SIZE * 6,
height: TILE_SIZE * 3,
}}>
{[...Array(18)].map((_, i) => {
const image = imagesArray.at(i % imagesArray.length)
return (
<div
key={i}
style={{
display: 'flex',
height: TILE_SIZE,
width: TILE_SIZE,
}}>
{image && <Img height="100%" width="100%" src={image} />}
</div>
)
})}
{/* background overlay */}
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
position: 'absolute',
backgroundImage: `linear-gradient(to bottom, ${GRADIENT_TOP}, ${GRADIENT_BOTTOM})`,
opacity: 0.9,
}}
/>
</div>
{/* foreground text & images */}
<div
style={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
width: '100%',
height: '100%',
position: 'absolute',
color: 'white',
}}>
<div
style={{
color: 'white',
padding: 60,
fontSize: 40,
}}>
JOIN THE CONVERSATION
</div>
<div style={{display: 'flex'}}>
{imagesAcross.map((image, i) => {
return (
<div
key={i}
style={{
display: 'flex',
height: 172 + 15 * 2,
width: 172 + 15 * 2,
margin: -15,
border: `15px solid ${IMAGE_STROKE}`,
borderRadius: '50%',
overflow: 'hidden',
}}>
<Img height="100%" width="100%" src={image} />
</div>
)
})}
</div>
<div
style={{
padding: '75px 30px 0px',
fontSize: isLongTitle ? 55 : 65,
display: 'flex',
textAlign: 'center',
}}>
{record?.name || 'Starter Pack'}
</div>
<div
style={{
display: 'flex',
fontSize: 40,
justifyContent: 'center',
padding: '30px 30px 10px',
}}>
on <Butterfly width="65" style={{margin: '-7px 10px 0'}} /> Bluesky
</div>
</div>
</div>
)
}
-40
View File
@@ -1,40 +0,0 @@
import {envInt, envStr} from '@atproto/common'
export type Config = {
service: ServiceConfig
}
export type ServiceConfig = {
port: number
version?: string
appviewUrl: string
originVerify?: string
}
export type Environment = {
port?: number
version?: string
appviewUrl?: string
originVerify?: string
}
export const readEnv = (): Environment => {
return {
port: envInt('CARD_PORT'),
version: envStr('CARD_VERSION'),
appviewUrl: envStr('CARD_APPVIEW_URL'),
originVerify: envStr('CARD_ORIGIN_VERIFY'),
}
}
export const envToCfg = (env: Environment): Config => {
const serviceCfg: ServiceConfig = {
port: env.port ?? 3000,
version: env.version,
appviewUrl: env.appviewUrl ?? 'https://api.bsky.app',
originVerify: env.originVerify,
}
return {
service: serviceCfg,
}
}
-46
View File
@@ -1,46 +0,0 @@
import {readdirSync, readFileSync} from 'node:fs'
import * as path from 'node:path'
import {fileURLToPath} from 'node:url'
import {AtpAgent} from '@atproto/api'
import {Config} from './config.js'
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
export type AppContextOptions = {
cfg: Config
appviewAgent: AtpAgent
fonts: {name: string; data: Buffer}[]
}
export class AppContext {
cfg: Config
appviewAgent: AtpAgent
fonts: {name: string; data: Buffer}[]
abortController = new AbortController()
constructor(private opts: AppContextOptions) {
this.cfg = this.opts.cfg
this.appviewAgent = this.opts.appviewAgent
this.fonts = this.opts.fonts
}
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
const fontFiles = readdirSync(fontDirectory)
const fonts = fontFiles.map(file => {
return {
name: path.basename(file, path.extname(file)),
data: readFileSync(path.join(fontDirectory, file)),
}
})
return new AppContext({
cfg,
appviewAgent,
fonts,
...overrides,
})
}
}
-41
View File
@@ -1,41 +0,0 @@
import events from 'node:events'
import http from 'node:http'
import express from 'express'
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
import {Config} from './config.js'
import {AppContext} from './context.js'
import {default as routes, errorHandler} from './routes/index.js'
export * from './config.js'
export * from './logger.js'
export class CardService {
public server?: http.Server
private terminator?: HttpTerminator
constructor(public app: express.Application, public ctx: AppContext) {}
static async create(cfg: Config): Promise<CardService> {
let app = express()
const ctx = await AppContext.fromConfig(cfg)
app = routes(ctx, app)
app.use(errorHandler)
return new CardService(app, ctx)
}
async start() {
this.server = this.app.listen(this.ctx.cfg.service.port)
this.server.keepAliveTimeout = 90000
this.terminator = createHttpTerminator({server: this.server})
await events.once(this.server, 'listening')
}
async destroy() {
this.ctx.abortController.abort()
await this.terminator?.terminate()
}
}
-4
View File
@@ -1,4 +0,0 @@
import {subsystemLogger} from '@atproto/common'
export const httpLogger = subsystemLogger('bskyogcard')
export const renderLogger = subsystemLogger('bskyogcard:render')
-14
View File
@@ -1,14 +0,0 @@
import {Express} from 'express'
import {AppContext} from '../context.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/_health',
handler(async (_req, res) => {
const {version} = ctx.cfg.service
return res.send({version})
}),
)
}
-13
View File
@@ -1,13 +0,0 @@
import {Express} from 'express'
import {AppContext} from '../context.js'
import {default as health} from './health.js'
import {default as starterPack} from './starter-pack.js'
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
return app
}
-108
View File
@@ -1,108 +0,0 @@
import assert from 'node:assert'
import React from 'react'
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {Express} from 'express'
import satori from 'satori'
import {
StarterPack,
STARTERPACK_HEIGHT,
STARTERPACK_WIDTH,
} from '../components/StarterPack.js'
import {AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {loadEmojiAsSvg} from '../util.js'
import {handler, originVerifyMiddleware} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/start/:actor/:rkey',
originVerifyMiddleware(ctx),
handler(async (req, res) => {
const {actor, rkey} = req.params
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()},
)
starterPack = result.data.starterPack
} catch (err) {
httpLogger.warn(
{err, uri: uri.toString()},
'could not fetch starter pack',
)
return res.status(404).end('not found')
}
const imageEntries = await Promise.all(
[starterPack.creator]
.concat(starterPack.listItemsSample.map(li => li.subject))
// has avatar
.filter(p => p.avatar)
// no sensitive labels
.filter(p => !p.labels.some(l => hideAvatarLabels.has(l.val)))
.map(async p => {
try {
assert(p.avatar)
const image = await getImage(p.avatar)
return [p.did, image] as const
} catch (err) {
httpLogger.warn(
{err, uri: uri.toString(), did: p.did},
'could not fetch image',
)
return [p.did, null] as const
}
}),
)
const images = new Map(
imageEntries.filter(([_, image]) => image !== null).slice(0, 7),
)
const svg = await satori(
<StarterPack starterPack={starterPack} images={images} />,
{
fonts: ctx.fonts,
height: STARTERPACK_HEIGHT,
width: STARTERPACK_WIDTH,
loadAdditionalAsset: async (code, text) => {
if (code === 'emoji') {
return await loadEmojiAsSvg(text)
}
},
},
)
const output = await resvg.renderAsync(svg)
res.statusCode = 200
res.setHeader('content-type', 'image/png')
res.setHeader('cdn-tag', [...images.keys()].join(','))
return res.end(output.asPng())
}),
)
}
async function getImage(url: string) {
const response = await fetch(url)
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
if (response.status !== 200) return null
return Buffer.from(arrayBuf)
}
const hideAvatarLabels = new Set([
'!hide',
'!warn',
'porn',
'sexual',
'nudity',
'sexual-figurative',
'graphic-media',
'self-harm',
'sensitive',
'security',
'impersonation',
'scam',
'spam',
'misleading',
'inauthentic',
])
-36
View File
@@ -1,36 +0,0 @@
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
import {AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
export type Handler = (req: Request, res: Response) => Awaited<void>
export const handler = (runHandler: Handler): RequestHandler => {
return async (req, res, next) => {
try {
await runHandler(req, res)
} catch (err) {
next(err)
}
}
}
export function originVerifyMiddleware(ctx: AppContext): RequestHandler {
const {originVerify} = ctx.cfg.service
if (!originVerify) return (_req, _res, next) => next()
return (req, res, next) => {
const verifyHeader = req.headers['x-origin-verify']
if (verifyHeader !== originVerify) {
return res.status(404).end('not found')
}
next()
}
}
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
httpLogger.error({err}, 'request error')
if (res.headersSent) {
return next(err)
}
return res.status(500).end('server error')
}
-37
View File
@@ -1,37 +0,0 @@
import twemoji from 'twemoji'
import {renderLogger} from './logger.js'
const U200D = String.fromCharCode(0x200d)
const UFE0F_REGEXP = /\uFE0F/g
export async function loadEmojiAsSvg(chars: string) {
const cached = emojiCache.get(chars)
if (cached) return cached
const iconCode = twemoji.convert.toCodePoint(
chars.indexOf(U200D) < 0 ? chars.replace(UFE0F_REGEXP, '') : chars,
)
const res = await fetch(getEmojiUrl(iconCode))
const body = await res.arrayBuffer()
if (!res.ok) {
renderLogger.warn(
{status: res.status, err: Buffer.from(body).toString()},
'could not fetch emoji',
)
return
}
const svg =
'data:image/svg+xml;base64,' + Buffer.from(body).toString('base64')
emojiCache.set(chars, svg)
return svg
}
const emojiCache = new Map<string, string>()
function getEmojiUrl(code: string) {
return (
'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/' +
code.toLowerCase() +
'.svg'
)
}
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"esModuleInterop": true,
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"outDir": "dist"
},
"include": ["./src/index.ts", "./src/bin.ts"]
}
-1269
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -24,7 +24,7 @@ Then build and copy over the big 'ol `bundle.web.js` file:
### Golang Daemon
Install golang. We are generally using v1.22+.
Install golang. We are generally using v1.21+.
In this directory (`bskyweb/`):
+1 -14
View File
@@ -35,17 +35,11 @@ func run(args []string) {
Flags: []cli.Flag{
&cli.StringFlag{
Name: "appview-host",
Usage: "scheme, hostname, and port of PDS instance",
Usage: "method, hostname, and port of PDS instance",
Value: "http://localhost:2584",
// retain old PDS env var for easy transition
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
},
&cli.StringFlag{
Name: "ogcard-host",
Usage: "scheme, hostname, and port of ogcard service",
Required: false,
EnvVars: []string{"OGCARD_HOST"},
},
&cli.StringFlag{
Name: "http-address",
Usage: "Specify the local IP/port to bind to",
@@ -53,13 +47,6 @@ func run(args []string) {
Value: ":8100",
EnvVars: []string{"HTTP_ADDRESS"},
},
&cli.StringFlag{
Name: "link-host",
Usage: "scheme, hostname, and port of link service",
Required: false,
Value: "",
EnvVars: []string{"LINK_HOST"},
},
&cli.BoolFlag{
Name: "debug",
Usage: "Enable debug mode",
-95
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
@@ -31,23 +30,12 @@ type Server struct {
echo *echo.Echo
httpd *http.Server
xrpcc *xrpc.Client
cfg *Config
}
type Config struct {
debug bool
httpAddress string
appviewHost string
ogcardHost string
linkHost string
}
func serve(cctx *cli.Context) error {
debug := cctx.Bool("debug")
httpAddress := cctx.String("http-address")
appviewHost := cctx.String("appview-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
// Echo
e := echo.New()
@@ -83,13 +71,6 @@ func serve(cctx *cli.Context) error {
server := &Server{
echo: e,
xrpcc: xrpcc,
cfg: &Config{
debug: debug,
httpAddress: httpAddress,
appviewHost: appviewHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
},
}
// Create the HTTP server.
@@ -226,7 +207,6 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID", server.WebProfile)
e.GET("/profile/:handleOrDID/follows", server.WebGeneric)
e.GET("/profile/:handleOrDID/followers", server.WebGeneric)
e.GET("/profile/:handleOrDID/known-followers", server.WebGeneric)
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
@@ -240,18 +220,6 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
if linkHost != "" {
linkUrl, err := url.Parse(linkHost)
if err != nil {
return err
}
e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl))
}
// Start the server.
log.Infof("starting server address=%s", httpAddress)
go func() {
@@ -323,30 +291,6 @@ func (srv *Server) Download(c echo.Context) error {
return c.Redirect(http.StatusFound, "/")
}
// Handler for proxying top-level paths to link service, which ends up serving a redirect
func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
return middleware.ProxyWithConfig(
middleware.ProxyConfig{
Balancer: middleware.NewRoundRobinBalancer(
[]*middleware.ProxyTarget{{URL: url}},
),
Skipper: func(c echo.Context) bool {
req := c.Request()
if req.Method == "GET" &&
strings.LastIndex(strings.TrimRight(req.URL.Path, "/"), "/") == 0 && // top-level path
!strings.HasPrefix(req.URL.Path, "/_") { // e.g. /_health endpoint
return false
}
return true
},
RetryCount: 2,
ErrorHandler: func(c echo.Context, err error) error {
return c.Redirect(302, "/")
},
},
)
}
// handler for endpoint that have no specific server-side handling
func (srv *Server) WebGeneric(c echo.Context) error {
data := pongo2.Context{}
@@ -432,45 +376,6 @@ func (srv *Server) WebPost(c echo.Context) error {
return c.Render(http.StatusOK, "post.html", data)
}
func (srv *Server) WebStarterPack(c echo.Context) error {
req := c.Request()
ctx := req.Context()
data := pongo2.Context{}
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
// sanity check arguments. don't 4xx, just let app handle if not expected format
rkeyParam := c.Param("rkey")
rkey, err := syntax.ParseRecordKey(rkeyParam)
if err != nil {
log.Errorf("bad rkey: %v", err)
return c.Render(http.StatusOK, "starterpack.html", data)
}
handleOrDIDParam := c.Param("handleOrDID")
handleOrDID, err := syntax.ParseAtIdentifier(handleOrDIDParam)
if err != nil {
log.Errorf("bad identifier: %v", err)
return c.Render(http.StatusOK, "starterpack.html", data)
}
identifier := handleOrDID.Normalize().String()
starterPackURI := fmt.Sprintf("at://%s/app.bsky.graph.starterpack/%s", identifier, rkey)
spv, err := appbsky.GraphGetStarterPack(ctx, srv.xrpcc, starterPackURI)
if err != nil {
log.Errorf("failed to fetch starter pack view for: %s\t%v", starterPackURI, err)
return c.Render(http.StatusOK, "starterpack.html", data)
}
if spv.StarterPack == nil || spv.StarterPack.Record == nil {
return c.Render(http.StatusOK, "starterpack.html", data)
}
rec, ok := spv.StarterPack.Record.Val.(*appbsky.GraphStarterpack)
if !ok {
return c.Render(http.StatusOK, "starterpack.html", data)
}
data["title"] = rec.Name
if srv.cfg.ogcardHost != "" {
data["imgThumbUrl"] = fmt.Sprintf("%s/start/%s/%s", srv.cfg.ogcardHost, identifier, rkey)
}
return c.Render(http.StatusOK, "starterpack.html", data)
}
func (srv *Server) WebProfile(c echo.Context) error {
ctx := c.Request().Context()
data := pongo2.Context{}

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