Compare commits

..

2 Commits

Author SHA1 Message Date
Samuel Newman 1de8819d9d Match renamed push payload fields from chat service
Server-side renamed `convoGroupName` -> `groupName` and `convoMemberCount`
-> `groupMemberCount` in the additional payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:03:56 +03:00
Samuel Newman b1923cab4d Fix group chat push notifications rendering as direct messages on iOS
iOS classifies Communication Notifications as either MessagingDirect or
MessagingGroup based on recipient count on the INSendMessageIntent. Our
push payload omits the recipient list, so iOS sees zero recipients and
falls back to MessagingDirect, ignoring speakableGroupName.

Use INSendMessageIntentDonationMetadata.recipientCount with the new
convoMemberCount field from the chat service to tell iOS the conversation
has multiple participants. Apple documents this as the supported escape
hatch when the full recipient list is too large to ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:44:09 +03:00
476 changed files with 90673 additions and 161381 deletions
-1
View File
@@ -1 +0,0 @@
alt-blur@3076a2b5a593b0d7b37198b5aebfe3a222afd52d
-44
View File
@@ -1,44 +0,0 @@
version: 2
# Dependabot auto-update config.
#
# Cooldown (7 days) is the point of this config: it delays version-update
# PRs until a newly-published version has aged. Supply-chain attacks like
# the tanstack Shai-Hulud compromise (2026-05-11) live minutes-to-hours
# before the registry yanks them; a 7-day cooldown keeps poisoned
# versions out of our lockfiles.
#
# Security updates bypass cooldown and continue to flow immediately. See:
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown
#
# Auto-merge is deliberately NOT enabled. Every dependabot PR gets human
# review.
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
production:
dependency-type: production
update-types: [minor, patch]
development:
dependency-type: development
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
open-pull-requests-limit: 3
groups:
actions:
patterns: ["*"]
update-types: [minor, patch]
+6 -7
View File
@@ -37,7 +37,7 @@ jobs:
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: 🪛 Setup jq
@@ -47,10 +47,9 @@ jobs:
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@v5
@@ -128,7 +127,7 @@ jobs:
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v2.1.1
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
@@ -138,7 +137,7 @@ jobs:
- name: 🔔 Notify Slack of Testflight Build
if: ${{ inputs.profile != 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v2.1.1
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
@@ -174,7 +173,7 @@ jobs:
- name: 🔔 Notify Slack of Production APK Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v2.1.1
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
+4 -5
View File
@@ -37,7 +37,7 @@ jobs:
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: 🪛 Setup jq
@@ -47,10 +47,9 @@ jobs:
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- uses: maxim-lobanov/setup-xcode@v1
@@ -155,7 +154,7 @@ jobs:
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v2.1.1
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
+16 -19
View File
@@ -61,7 +61,7 @@ jobs:
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: 📷 Check fingerprint and install dependencies
@@ -71,27 +71,26 @@ jobs:
profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }}
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
run: pnpm lint
- name: Prettier check
run: pnpm prettier --check .
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Type check
run: pnpm typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: 🪛 Setup jq
@@ -176,14 +175,13 @@ jobs:
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⚙️ Install dependencies
@@ -323,14 +321,13 @@ jobs:
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@v5
@@ -386,7 +383,7 @@ jobs:
path: build.apk
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v2.1.1
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
+1 -1
View File
@@ -51,4 +51,4 @@ jobs:
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-8
--model claude-opus-4-7
-2
View File
@@ -20,7 +20,6 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
@@ -38,7 +37,6 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
+26 -59
View File
@@ -6,43 +6,34 @@ on:
branches:
- main
concurrency:
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
cancel-in-progress: true
# These jobs only check out the repo and run checks, so read access to the
# repo contents is all the GITHUB_TOKEN needs.
permissions:
contents: read
jobs:
linting:
name: Run linters
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
job: [lint, prettier, typecheck]
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- name: Verify Node version pins match package.json
- name: Verify Node version pins match .nvmrc
run: |
set -euo pipefail
expected=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g, '')")
expected=$(tr -d '[:space:]' < .nvmrc)
rc=0
check() {
if [ "$2" != "$expected" ]; then
echo "::error file=$1::Node version mismatch: $1 pins '$2' but package.json is '$expected'"
echo "::error file=$1::Node version mismatch: $1 pins '$2' but .nvmrc is '$expected'"
rc=1
fi
}
# FROM node:X.Y.Z - service runtime images
# FROM node:X.Y.Z service runtime images
for f in Dockerfile.bskylink Dockerfile.bskyogcard; do
v=$(grep -oE 'FROM node:[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
# ENV NODE_VERSION=X.Y.Z - Go images that nvm-install Node for the JS build stage
for f in Dockerfile.embedr; do
# ENV NODE_VERSION=X.Y.Z Go images that nvm-install Node for the JS build stage
for f in Dockerfile Dockerfile.embedr; do
v=$(grep -oE 'NODE_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
@@ -54,37 +45,25 @@ jobs:
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: pnpm install
run: pnpm install --frozen-lockfile
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Lint check
run: pnpm lint
- name: Prettier check
run: pnpm prettier --check .
- name: Check & compile i18n
run: pnpm intl:build
- name: Lint checks
run: pnpm ${{ matrix.job }}
# Aggregates the matrix results into a single stable check name so branch
# protection can require "Run linters" regardless of how many matrix jobs run.
# The result is asserted in `run` (not `if`) so a malformed expression can
# never silently skip the check and report a false pass.
linting-summary:
name: Run linters
if: always()
needs: [linting]
runs-on: ubuntu-latest
steps:
- name: Require linting to have succeeded
env:
RESULT: ${{ needs.linting.result }}
run: |
echo "linting result: $RESULT"
test "$RESULT" = "success"
- name: Type check
run: pnpm typecheck
testing:
name: Run tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Check out Git repository
uses: actions/checkout@v5
@@ -92,28 +71,16 @@ jobs:
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: pnpm install
run: pnpm install --frozen-lockfile
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Check & compile i18n
run: pnpm intl:build
- name: Run tests
run: |
NODE_ENV=test pnpm test --forceExit --shard=${{ matrix.shard }}/${{ strategy.job-total }}
# Aggregates the sharded test results into a single stable check name so branch
# protection can require "Run tests" regardless of how many shards run.
# The result is asserted in `run` (not `if`) so a malformed expression can
# never silently skip the check and report a false pass.
testing-summary:
name: Run tests
if: always()
needs: [testing]
runs-on: ubuntu-latest
steps:
- name: Require testing to have succeeded
env:
RESULT: ${{ needs.testing.result }}
run: |
echo "testing result: $RESULT"
test "$RESULT" = "success"
NODE_ENV=test pnpm test --forceExit
@@ -23,10 +23,14 @@ jobs:
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: pnpm install
run: pnpm install --frozen-lockfile
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Extract language strings
run: pnpm intl:extract
- name: Create commit
+9 -10
View File
@@ -94,7 +94,7 @@ jobs:
core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
@@ -122,29 +122,28 @@ jobs:
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint check
run: pnpm lint
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: 🪛 Setup jq
@@ -181,7 +180,7 @@ jobs:
RUNTIME_VERSION:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
with:
@@ -198,7 +197,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
if: failure()
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
+5 -5
View File
@@ -33,7 +33,7 @@ jobs:
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: Ensure tracking relevant branches and checkout base
@@ -96,7 +96,7 @@ jobs:
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
with:
header: bundle-diff
message: |
@@ -123,7 +123,7 @@ jobs:
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
cache: pnpm
- name: 📷 Check fingerprint and install dependencies
@@ -133,7 +133,7 @@ jobs:
profile: pull-request
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
@@ -151,7 +151,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Delete comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
fetch-depth: 0
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
+7 -3
View File
@@ -26,7 +26,7 @@ jobs:
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
- name: Reset pnpm-lock.yaml to base
env:
@@ -34,8 +34,12 @@ jobs:
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
- name: pnpm install
# Fine to skip scripts since we don't run any code
run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
uses: Wandalen/wretry.action@master
with:
# Fine to skip scripts since we don't run any code
command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Verify pnpm-lock.yaml
run: |
-1
View File
@@ -133,4 +133,3 @@ bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
.claude/worktrees
+3 -7
View File
@@ -206,7 +206,7 @@ function MyComponent() {
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
</View>
)
}
@@ -414,16 +414,12 @@ import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
// will almost certainly contain emoji, so only omit it when the text is static and
// does not contain an emoji
// For text with emoji, add the emoji prop
<Text emoji>Hello! 👋</Text>
```
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
### TextField
```tsx
+41 -49
View File
@@ -1,21 +1,23 @@
#
# Stage 1: build the web bundle with pnpm.
#
# Uses the official pnpm image. Node is auto-downloaded by pnpm using the
# `devEngines.runtime` field in package.json (onFail: "download").
#
FROM ghcr.io/pnpm/pnpm:11 AS web-build
FROM golang:1.26-bookworm AS build-env
WORKDIR /app
WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
#
# pnpm config
# Node
#
ENV CI=1
# use the pnpm version specified in package.json
ENV pnpm_config_pm_on_fail=download
ENV NODE_VERSION=24.15.0
ENV NVM_DIR=/usr/share/nvm
#
# Go
#
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
# The latest git hash of the preview branch on render.com
# https://render.com/docs/docker-secrets#environment-variables-in-docker-builds
@@ -40,48 +42,41 @@ ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN:-unknown}
ARG EXPO_PUBLIC_SENTRY_DSN
ENV EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN
#
# Copy everything into the container
#
COPY . .
RUN echo "Using bundle identifier: $EXPO_PUBLIC_BUNDLE_IDENTIFIER" && \
#
# Generate the JavaScript webpack.
#
RUN mkdir --parents $NVM_DIR && \
wget \
--output-document=/tmp/nvm-install.sh \
https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh && \
bash /tmp/nvm-install.sh
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
echo "Using bundle identifier: $EXPO_PUBLIC_BUNDLE_IDENTIFIER" && \
echo "EXPO_PUBLIC_ENV=$EXPO_PUBLIC_ENV" >> .env && \
echo "EXPO_PUBLIC_RELEASE_VERSION=$EXPO_PUBLIC_RELEASE_VERSION" >> .env && \
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER" >> .env && \
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env && \
echo "EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN" >> .env
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm intl:build 2>&1 | tee i18n.log && \
if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compile errors!\n\n"; fi
RUN SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN \
SENTRY_RELEASE=$EXPO_PUBLIC_RELEASE_VERSION \
SENTRY_DIST=$EXPO_PUBLIC_BUNDLE_IDENTIFIER \
pnpm build-web
#
# Stage 2: build the bskyweb Go binary, embedding the assets from stage 1.
#
# post-web-build.js (run by `pnpm build-web`) writes the bundled JS/CSS/media
# into bskyweb/static/* and regenerates bskyweb/templates/scripts.html, so
# copying the bskyweb/ tree from stage 1 is enough for go:embed to find
# everything.
#
FROM golang:1.26-bookworm AS go-build
WORKDIR /usr/src/social-app
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
COPY --from=web-build /app/bskyweb ./bskyweb
echo "EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN" >> .env && \
npm install --global pnpm@11.1.1 && \
pnpm install --frozen-lockfile && \
pnpm intl:build 2>&1 | tee i18n.log && \
if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compile errors!\n\n"; fi && \
SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN SENTRY_RELEASE=$EXPO_PUBLIC_RELEASE_VERSION SENTRY_DIST=$EXPO_PUBLIC_BUNDLE_IDENTIFIER pnpm build-web
# DEBUG
RUN find ./bskyweb/static
RUN find ./bskyweb/static && find ./web-build/static
#
# Generate the bskyweb Go binary.
#
RUN cd bskyweb/ && \
go mod download && \
go mod verify
@@ -94,9 +89,6 @@ RUN cd bskyweb/ && \
-o /bskyweb \
./cmd/bskyweb
#
# Stage 3: runtime image.
#
FROM debian:bookworm-slim
ENV GODEBUG=netdns=go
@@ -110,7 +102,7 @@ RUN apt-get update && apt-get install --yes \
ENTRYPOINT ["dumb-init", "--"]
WORKDIR /bskyweb
COPY --from=go-build /bskyweb /usr/bin/bskyweb
COPY --from=build-env /bskyweb /usr/bin/bskyweb
CMD ["/usr/bin/bskyweb"]
+4 -9
View File
@@ -1,22 +1,17 @@
FROM node:24.15.0-alpine3.22 AS build
# Tells pnpm to run non-interactively (needed for install/script steps)
ENV CI=true
# Move files into the image and install
WORKDIR /app
COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/pnpm-lock.yaml ./
COPY ./bskyogcard/pnpm-workspace.yaml ./
RUN npm install --global pnpm@11.1.3
RUN pnpm install --frozen-lockfile
COPY ./bskyogcard/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY ./bskyogcard ./
# build then prune dev deps
RUN pnpm install-fonts && pnpm build
RUN pnpm install --prod --ignore-scripts --prefer-offline
RUN yarn install-fonts && yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:24.15.0-alpine3.22
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global pnpm@11.5.2 && \
npm install --global pnpm@11.1.3 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
+12 -23
View File
@@ -1,12 +1,11 @@
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
import {
downloadAndResize,
type DownloadAndResizeOpts,
getResizedDimensions,
} from '../../src/lib/media/manip'
import {getResizedDimensions} from '../../src/lib/media/util'
const mockResizedImage = {
path: 'file://resized-image.jpg',
@@ -42,8 +41,10 @@ describe('downloadAndResize', () => {
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.jpg',
maxDimension: 2000,
width: 100,
height: 100,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
@@ -59,11 +60,9 @@ describe('downloadAndResize', () => {
// First time it gets called is to get dimensions
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
// The mocked source image is 100x100, below maxDimension, so it is not
// downsized.
expect(manipulateAsync).toHaveBeenCalledWith(
expect.any(String),
[{resize: {height: 100, width: 100}}],
[{resize: {height: opts.height, width: opts.width}}],
{format: SaveFormat.JPEG, compress: 1.0},
)
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
@@ -74,8 +73,10 @@ describe('downloadAndResize', () => {
it('should return undefined for invalid URI', async () => {
const opts: DownloadAndResizeOpts = {
uri: 'invalid-uri',
maxDimension: 2000,
width: 100,
height: 100,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
@@ -89,19 +90,13 @@ describe('downloadAndResize', () => {
width: 1200,
height: 1000,
}
const resizedDimensionsOne = getResizedDimensions(
initialDimensionsOne,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
const initialDimensionsTwo = {
width: 1000,
height: 1200,
}
const resizedDimensionsTwo = getResizedDimensions(
initialDimensionsTwo,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
@@ -112,19 +107,13 @@ describe('downloadAndResize', () => {
width: 3000,
height: 1500,
}
const resizedDimensionsOne = getResizedDimensions(
initialDimensionsOne,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
const initialDimensionsTwo = {
width: 2000,
height: 4000,
}
const resizedDimensionsTwo = getResizedDimensions(
initialDimensionsTwo,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
expect(resizedDimensionsOne).toEqual({
width: 2000,
-45
View File
@@ -1,7 +1,6 @@
import {describe, expect, it} from '@jest/globals'
import {
getChatInviteCodeFromUrl,
isPossiblyAUrl,
isTrustedUrl,
linkRequiresWarning,
@@ -179,47 +178,3 @@ describe('isTrustedUrl', () => {
expect(output).toEqual(expected)
})
})
describe('getChatInviteCodeFromUrl', () => {
type Case = [string, string | undefined]
const cases: Case[] = [
['https://bsky.app/chat/abcdefg', 'abcdefg'],
['https://bsky.app/chat/abcdefghij', 'abcdefghij'],
// http is not recognized as a bsky.app url
['http://bsky.app/chat/abcdefg', undefined],
['https://bsky.app/chat/abcdefg?utm=foo', 'abcdefg'],
['https://bsky.app/chat/abcdefg#section', 'abcdefg'],
['/chat/abcdefg', 'abcdefg'],
['/chat/abcdefg?utm=foo', 'abcdefg'],
['/chat/abcdefg#section', 'abcdefg'],
// too short
['https://bsky.app/chat/abcdef', undefined],
['/chat/abcdef', undefined],
// too long
['https://bsky.app/chat/abcdefghijk', undefined],
['/chat/abcdefghijk', undefined],
// invalid characters
['https://bsky.app/chat/abc-def', undefined],
['/chat/abc def', undefined],
// trailing path
['https://bsky.app/chat/abcdefg/extra', undefined],
['/chat/abcdefg/extra', undefined],
// wrong path
['https://bsky.app/profile/abcdefg', undefined],
['https://bsky.app/chat', undefined],
// wrong host
['https://example.com/chat/abcdefg', undefined],
// not a url, not a path
['chat/abcdefg', undefined],
['abcdefg', undefined],
['', undefined],
// malformed url
['https://[invalid/chat/abcdefg', undefined],
]
it.each(cases)('given input %p, returns %p', (input, expected) => {
expect(getChatInviteCodeFromUrl(input)).toEqual(expected)
})
})
+1 -1
View File
@@ -349,7 +349,7 @@ module.exports = function (_config) {
},
],
[
'@bsky.app/expo-dynamic-app-icon',
'@mozzius/expo-dynamic-app-icon',
{
/**
* Default set
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17.058 1.001c.466-.012 1.294.05 1.828.782.099.136.172.274.224.405.385-.036.92-.08 1.416-.039.48.04 1.448.197 2.012 1.09.497.787.372 1.592.13 2.14-.14.315-.345.602-.505.807.172.15.366.354.518.613.366.622.383 1.312.22 1.962-.285 1.143-1.24 1.642-1.82 1.865-.597.23-1.296.354-1.818.448l-.06.01-.121.022c.128.445.101.918-.048 1.367-.41 1.242-1.349 1.831-2.24 2.08-.764.214-1.618.213-2.225.212h-.218c.152.615-.022 1.221-.192 1.631-.63 1.514-2.053 2.08-3.203 2.319s-2.484.247-3.591.253h-.076c-.607.004-1.145.007-1.616.034-.417.806-.763 1.702-1.163 2.75l-.002.002q-.158.414-.327.85c-.446 1.14-1.709 1.693-2.82 1.235-1.11-.459-1.65-1.755-1.204-2.897.571-1.462 1.164-2.48 1.954-4.046.212-.42.39-.76.547-1.075 0-.951.07-1.624.207-2.476.23-1.432.423-1.984.707-3.29.26-.903.644-2.115 1.247-2.783.346-.384.88-.945 1.685-.983.489-.023.794.38 1.052.553.749-1.04 1.384-2.427 2.202-3.503.657-.863 1.363-1.618 2.503-1.438.588.093 1.423.547 1.424 1.438.217-.249.883-1.204 1.162-1.438.532-.445 1.27-.874 2.21-.9m-.42 1.154c-1.003.028-1.71.904-2.303 1.64-.43.532-.8.991-1.18 1.001-.373.01-.345-.247-.31-.572.05-.464.115-1.066-.945-1.234-.879-.14-1.805 1.531-2.61 2.981-.551.993-1.046 1.883-1.428 2.019-.584.208-.854-.037-1.085-.245-.28-.252-.5-.451-1.147.265-.681.755-1.146 2.82-1.596 5.068-.057.287.342.472.525.247 1.036-1.28 2.72-3.039 4.73-4.34 1.848-1.196 3.63-2.385 5.647-2.983.297-.088.38.253.096.377-2.273.994-4.226 2.304-6.272 4.053-2.299 1.923-4.474 4.483-5.577 6.57a27 27 0 0 0-1.829 3.786.724.724 0 0 0 .388.933.693.693 0 0 0 .91-.398q.162-.418.316-.825c.473-1.234.915-2.391 1.539-3.443a.25.25 0 0 1 .187-.123c.678-.082 1.485-.087 2.32-.092 2.277-.013 4.764-.028 5.431-1.632.348-.838-.071-.959-.45-1.067-.31-.089-.593-.17-.406-.629.323-.792 1.377-.793 2.494-.793 1.301 0 2.687 0 3.103-1.254.177-.537-.14-.748-.42-.934-.235-.157-.444-.296-.317-.596.201-.477 1.078-.636 2.007-.803 1.11-.2 2.294-.412 2.49-1.198.186-.744-.16-.983-.52-1.232-.218-.151-.44-.307-.55-.582-.133-.337.158-.697.461-1.071.358-.442.731-.903.438-1.367-.334-.527-1.327-.434-2.15-.358-.512.048-.959.089-1.136-.03-.18-.12-.173-.325-.166-.528.011-.317.022-.63-.685-.611"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

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

Before

Width:  |  Height:  |  Size: 3.0 KiB

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

Before

Width:  |  Height:  |  Size: 1.9 KiB

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

+8 -16
View File
@@ -3,38 +3,30 @@
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.1.3",
"onFail": "warn"
}
},
"scripts": {
"start": "tsx ./src/bin.ts",
"dev": "LOG_ENABLED=true LOG_LEVEL=debug node --watch-path ./src --import tsx ./src/bin.ts",
"dev": "node --watch-path ./src --import tsx ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/",
"install-fonts": "tsx scripts/install-fonts.ts",
"typecheck": "tsc --noEmit"
"install-fonts": "tsx scripts/install-fonts.ts"
},
"dependencies": {
"@atproto/api": "0.20.11",
"@atproto/common": "^0.6.1",
"@atproto/api": "0.12.19-next.0",
"@atproto/common": "^0.4.0",
"@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2",
"express-prom-bundle": "^7.0.0",
"http-terminator": "^3.2.0",
"pino": "^9.2.0",
"prom-client": "^15.1.3",
"react": "^19.2.6",
"satori": "^0.26.0",
"react": "^18.3.1",
"satori": "^0.10.13",
"twemoji": "^14.0.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^24.12.2",
"@types/react": "^19.2.15",
"@types/react": "^18.3.1",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
"typescript": "^6.0.2"
}
}
-2029
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
nodeLinker: 'hoisted'
autoInstallPeers: true # default: true
strictPeerDependencies: false # default: false
trustPolicy: 'no-downgrade' # default: off
trustPolicyIgnoreAfter: 10080 # 7 days, default: undefined
minimumReleaseAgeExclude:
- '@atproto/*'
- '@bsky.app/*'
allowBuilds:
cbor-extract: true
esbuild: true
Binary file not shown.
Binary file not shown.
Binary file not shown.
-526
View File
@@ -1,526 +0,0 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text */
import {ChatBskyGroupDefs} from '@atproto/api'
import {Img} from './Img.js'
export const CHAT_INVITE_HEIGHT = 630
export const CHAT_INVITE_WIDTH = 1200
const PADDING = 64
export function ChatInvite({
preview,
ownerImage,
}: {
preview: ChatBskyGroupDefs.JoinLinkPreviewView
ownerImage: Buffer<ArrayBufferLike> | null
}) {
return (
<div
style={{
width: CHAT_INVITE_WIDTH,
height: CHAT_INVITE_HEIGHT,
display: 'flex',
position: 'relative',
backgroundColor: '#006AFF',
}}>
<Background
style={{
position: 'absolute',
inset: 0,
}}
/>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
padding: PADDING,
color: 'white',
fontFamily: 'Inter',
}}>
{/* Avatar */}
<div
style={{
display: 'flex',
width: 160,
height: 160,
borderRadius: '50%',
overflow: 'hidden',
}}>
{ownerImage && <Img src={ownerImage} width="100%" height="100%" />}
</div>
{/* Spacer pushes the text block to the bottom-left */}
<div style={{display: 'flex', flex: 1}} />
{/* Group Chat row */}
<div
style={{
display: 'flex',
alignItems: 'center',
fontSize: 26,
fontWeight: 600,
lineHeight: 1.3,
}}>
<ChatIcon style={{marginRight: 8, marginTop: -2}} />
Group Chat
</div>
{/* Chat name */}
<div
style={{
display: 'block',
fontSize: 80,
fontWeight: 700,
lineHeight: 1.1,
marginTop: 6,
minHeight: 130,
maxWidth: 1000,
wordBreak: 'break-word',
lineClamp: 2,
textOverflow: 'ellipsis',
overflow: 'hidden',
}}>
{preview.name}
</div>
{/* By @handle */}
<div
style={{
display: 'block',
fontSize: 34,
fontWeight: 400,
marginTop: 12,
lineHeight: 1.3,
lineClamp: 1,
textOverflow: 'ellipsis',
overflow: 'hidden',
maxWidth: 800,
wordBreak: 'break-all',
}}>
{'By @' + preview.owner.handle}
</div>
</div>
</div>
)
}
function ChatIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<path
d="M9.99935 14.3334C10.9198 14.3334 11.666 15.0796 11.666 16.0001C11.666 16.9206 10.9198 17.6667 9.99935 17.6667C9.07887 17.6667 8.33268 16.9206 8.33268 16.0001C8.33268 15.0796 9.07887 14.3334 9.99935 14.3334Z"
fill="white"
/>
<path
d="M15.9993 14.3334C16.9198 14.3334 17.666 15.0796 17.666 16.0001C17.666 16.9206 16.9198 17.6667 15.9993 17.6667C15.0789 17.6667 14.3327 16.9206 14.3327 16.0001C14.3327 15.0796 15.0789 14.3334 15.9993 14.3334Z"
fill="white"
/>
<path
d="M21.9993 14.3334C22.9198 14.3334 23.666 15.0796 23.666 16.0001C23.666 16.9206 22.9198 17.6667 21.9993 17.6667C21.0789 17.6667 20.3327 16.9206 20.3327 16.0001C20.3327 15.0796 21.0789 14.3334 21.9993 14.3334Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.9993 2.66675C23.3631 2.66675 29.3327 8.63628 29.3327 16.0001C29.3327 23.3639 23.3631 29.3334 15.9993 29.3334C14.0352 29.3334 12.167 28.9082 10.485 28.1433L4.24414 29.3113C3.80802 29.3927 3.35993 29.2513 3.04883 28.935C2.73759 28.6183 2.60409 28.1668 2.69336 27.7319L3.93424 21.6824C3.12049 19.9572 2.66602 18.0301 2.66602 16.0001C2.66602 8.63629 8.63556 2.66676 15.9993 2.66675ZM15.9993 5.33341C10.1083 5.33343 5.33268 10.1091 5.33268 16.0001C5.33268 17.7656 5.76102 19.4272 6.51758 20.8907C6.65753 21.1615 6.69993 21.4723 6.63867 21.7709L5.70378 26.3243L10.4212 25.4415C10.7058 25.3883 11.0005 25.4298 11.2598 25.5587C12.6861 26.2674 14.2941 26.6667 15.9993 26.6667C21.8904 26.6667 26.666 21.8911 26.666 16.0001C26.666 10.109 21.8904 5.33341 15.9993 5.33341Z"
fill="white"
/>
</svg>
)
}
function Background(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
width="1200"
height="630"
viewBox="0 0 1200 630"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<g clip-path="url(#clip0_3134_11977)">
<path
d="M938.799 522.018C944.511 526.47 950.654 535.496 952.91 540.34C955.166 535.496 961.309 526.47 967.021 522.018C971.142 518.806 977.82 516.32 977.82 524.229C977.82 525.808 976.948 537.498 976.436 539.396C974.658 545.993 968.178 547.676 962.413 546.657C972.489 548.438 975.053 554.335 969.517 560.232C957.062 573.5 952.91 553.134 952.91 553.134C952.91 553.134 948.758 573.5 936.303 560.232C930.768 554.335 933.331 548.438 943.407 546.657C937.642 547.676 931.162 545.993 929.384 539.396C928.872 537.498 928 525.808 928 524.229C928 516.32 934.678 518.806 938.799 522.018Z"
fill="white"
/>
<path
d="M1008.88 541.392C1012.31 542.653 1014.13 545.565 1014.13 548.825C1014.13 554.346 1010.53 557.693 1003.63 557.693H989.527V526.743H1003.16C1009.73 526.743 1012.9 530.177 1012.9 534.742C1012.9 537.784 1011.55 540.001 1008.88 541.392ZM1002.74 531.568H995.244V539.653H1002.74C1005.66 539.653 1007.23 538.089 1007.23 535.48C1007.23 533.09 1005.62 531.568 1002.74 531.568ZM995.244 552.824H1003.33C1006.55 552.824 1008.29 551.303 1008.29 548.564C1008.29 545.695 1006.64 544.261 1003.33 544.261H995.244V552.824Z"
fill="white"
/>
<path
d="M1022.06 557.693H1016.72V526.743H1022.06V557.693Z"
fill="white"
/>
<path
d="M1039.75 547.825V535.263H1045.09V557.693H1039.92V554.432C1038.27 556.954 1035.98 558.214 1033.06 558.214C1028.44 558.214 1025.44 555.345 1025.44 550.129V535.263H1030.77V549.216C1030.77 552.042 1032.13 553.476 1034.88 553.476C1037.46 553.476 1039.75 551.52 1039.75 547.825Z"
fill="white"
/>
<path
d="M1069.37 546.869V548.173H1053.03C1053.41 552.042 1055.48 553.954 1058.7 553.954C1061.16 553.954 1062.81 552.868 1063.7 550.738H1068.82C1067.68 555.302 1063.87 558.214 1058.66 558.214C1055.4 558.214 1052.77 557.127 1050.78 554.998C1048.79 552.868 1047.78 550.042 1047.78 546.478C1047.78 542.957 1048.75 540.132 1050.74 537.958C1052.73 535.828 1055.31 534.742 1058.57 534.742C1061.88 534.742 1064.5 535.872 1066.45 538.089C1068.4 540.305 1069.37 543.261 1069.37 546.869ZM1058.53 539.001C1055.61 539.001 1053.58 540.74 1053.07 544.348H1064.04C1063.57 541.088 1061.62 539.001 1058.53 539.001Z"
fill="white"
/>
<path
d="M1081.2 558.301C1074.85 558.301 1071.5 555.737 1071.2 550.564H1076.41C1076.71 553.346 1078.06 554.389 1081.28 554.389C1084.16 554.389 1085.6 553.476 1085.6 551.694C1085.6 550.086 1084.59 549.303 1081.33 548.738L1078.83 548.303C1074.04 547.478 1071.67 545.217 1071.67 541.522C1071.67 537.306 1074.93 534.742 1080.73 534.742C1086.96 534.742 1090.18 537.263 1090.39 542.348H1085.35C1085.22 539.61 1083.65 538.654 1080.73 538.654C1078.19 538.654 1076.92 539.523 1076.92 541.262C1076.92 542.827 1078.02 543.522 1080.56 544L1083.32 544.435C1088.61 545.435 1090.9 547.434 1090.9 551.259C1090.9 555.78 1087.34 558.301 1081.2 558.301Z"
fill="white"
/>
<path
d="M1114.31 557.693H1108.22L1101.86 547.26L1098.56 550.651V557.693H1093.31V526.743H1098.56V544.565L1107.45 535.263H1113.81L1105.55 543.739L1114.31 557.693Z"
fill="white"
/>
<path
d="M1128.25 540.914L1130.03 535.263H1135.62L1127.19 559.518C1126.3 561.996 1125.2 563.778 1123.8 564.778C1122.41 565.778 1120.42 566.256 1117.79 566.256C1116.9 566.256 1116.14 566.212 1115.46 566.125V561.822H1117.49C1119.91 561.822 1121.09 560.301 1121.09 558.214C1121.09 557.171 1120.75 555.65 1120.08 553.694L1113.72 535.263H1119.48L1121.26 540.87C1122.58 545.087 1123.72 549.26 1124.74 553.389C1125.67 549.825 1126.85 545.652 1128.25 540.914Z"
fill="white"
/>
<g filter="url(#filter0_d_3134_11977)">
<rect
x="236"
y="130"
width="145.6"
height="145.6"
rx="72.8"
fill="#4291FF"
shape-rendering="crispEdges"
/>
<path
d="M308.006 202.8C321.082 202.8 331.09 210.636 335.154 221.624C336.316 224.765 335.52 227.861 333.674 230.062C331.874 232.207 329.095 233.512 326.102 233.512H289.909C286.916 233.512 284.138 232.206 282.339 230.062C280.492 227.861 279.696 224.765 280.858 221.624C284.922 210.636 294.929 202.8 308.006 202.8ZM308.001 168.675C316.482 168.675 323.357 175.551 323.357 184.032C323.357 192.513 316.482 199.387 308.001 199.387C299.52 199.387 292.644 192.513 292.644 184.032C292.644 175.551 299.52 168.675 308.001 168.675Z"
fill="#006AFF"
/>
</g>
<g filter="url(#filter1_d_3134_11977)">
<rect
x="316"
y="-60"
width="191.1"
height="191.1"
rx="95.55"
fill="#4291FF"
shape-rendering="crispEdges"
/>
<path
d="M412.006 35.5498C429.441 35.55 442.784 45.9985 448.202 60.6484C449.751 64.8366 448.691 68.9642 446.229 71.8984C443.829 74.7581 440.125 76.4999 436.135 76.5H387.876C383.886 76.5 380.182 74.7581 377.783 71.8984C375.32 68.9642 374.26 64.8366 375.809 60.6484C381.227 45.9983 394.571 35.5498 412.006 35.5498ZM411.999 -9.9502C423.307 -9.95 432.474 -0.782514 432.474 10.5254C432.474 21.8332 423.307 30.9998 411.999 31C400.691 31 391.524 21.8333 391.524 10.5254C391.524 -0.782633 400.691 -9.9502 411.999 -9.9502Z"
fill="#006AFF"
/>
</g>
<g filter="url(#filter2_d_3134_11977)">
<rect
x="472"
y="84"
width="246"
height="246"
rx="123"
fill="#4291FF"
shape-rendering="crispEdges"
/>
<path
d="M596.01 207C618.454 207.001 635.631 220.449 642.606 239.308C644.6 244.7 643.235 250.014 640.065 253.792C636.976 257.472 632.207 259.714 627.071 259.714H564.949C559.812 259.714 555.044 257.473 551.955 253.792C548.785 250.014 547.42 244.7 549.414 239.308C556.388 220.449 573.566 207 596.01 207ZM596.002 148.428C610.558 148.428 622.359 160.229 622.359 174.786C622.359 189.342 610.558 201.143 596.002 201.143C581.445 201.143 569.644 189.342 569.644 174.786C569.644 160.229 581.445 148.428 596.002 148.428Z"
fill="#006AFF"
/>
</g>
<g filter="url(#filter3_d_3134_11977)">
<rect
x="870"
y="-22"
width="308.195"
height="308.195"
rx="154.098"
fill="#4291FF"
shape-rendering="crispEdges"
/>
<path
d="M1024.01 132.098C1052.13 132.098 1073.65 148.948 1082.39 172.574C1084.89 179.329 1083.18 185.987 1079.21 190.719C1075.34 195.331 1069.36 198.14 1062.93 198.14H985.098C978.663 198.14 972.689 195.331 968.819 190.719C964.848 185.987 963.138 179.329 965.636 172.574C974.374 148.947 995.894 132.098 1024.01 132.098ZM1024 58.7178C1042.24 58.7178 1057.02 73.5023 1057.02 91.7393C1057.02 109.976 1042.24 124.76 1024 124.76C1005.76 124.76 990.981 109.976 990.981 91.7393C990.981 73.5024 1005.76 58.7179 1024 58.7178Z"
fill="#006AFF"
/>
</g>
<g filter="url(#filter4_d_3134_11977)">
<rect
x="546"
y="-290"
width="374"
height="374"
rx="187"
fill="#4291FF"
shape-rendering="crispEdges"
/>
</g>
<g filter="url(#filter5_d_3134_11977)">
<rect
x="728"
y="222"
width="180"
height="180"
rx="90"
fill="#4291FF"
shape-rendering="crispEdges"
/>
<path
d="M818.006 312C834.429 312 846.998 321.841 852.101 335.64C853.56 339.585 852.561 343.473 850.242 346.237C847.981 348.931 844.492 350.571 840.734 350.571H795.278C791.519 350.571 788.03 348.931 785.77 346.237C783.451 343.473 782.453 339.585 783.912 335.64C789.015 321.841 801.584 312 818.006 312ZM817.999 269.143C828.651 269.143 837.286 277.778 837.286 288.429C837.285 299.08 828.651 307.714 817.999 307.714C807.348 307.714 798.714 299.08 798.714 288.429C798.714 277.778 807.348 269.143 817.999 269.143Z"
fill="#006AFF"
/>
</g>
<g filter="url(#filter6_d_3134_11977)">
<rect
x="1146"
y="220"
width="178"
height="178"
rx="89"
fill="#4291FF"
shape-rendering="crispEdges"
/>
</g>
</g>
<defs>
<filter
id="filter0_d_3134_11977"
x="204"
y="114"
width="209.6"
height="209.6"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter1_d_3134_11977"
x="284"
y="-76"
width="255.1"
height="255.1"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter2_d_3134_11977"
x="440"
y="68"
width="310"
height="310"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter3_d_3134_11977"
x="838"
y="-38"
width="372.195"
height="372.195"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter4_d_3134_11977"
x="514"
y="-306"
width="438"
height="438"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter5_d_3134_11977"
x="696"
y="206"
width="244"
height="244"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<filter
id="filter6_d_3134_11977"
x="1114"
y="204"
width="242"
height="242"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="16" />
<feGaussianBlur stdDeviation="16" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3134_11977"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3134_11977"
result="shape"
/>
</filter>
<clipPath id="clip0_3134_11977">
<rect width="1200" height="630" fill="white" />
</clipPath>
</defs>
</svg>
)
}
+1 -7
View File
@@ -3,7 +3,6 @@ import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {Butterfly} from './Butterfly.js'
import {Img} from './Img.js'
import * as bsky from '../types/bsky/index.js'
export const STARTERPACK_HEIGHT = 630
export const STARTERPACK_WIDTH = 1200
@@ -18,10 +17,7 @@ export function StarterPack(props: {
images: Map<string, Buffer>
}) {
const {starterPack, images} = props
const record = bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
starterPack.record,
AppBskyGraphStarterpack.isRecord,
)
const record = AppBskyGraphStarterpack.isRecord(starterPack.record)
? starterPack.record
: null
const imagesArray = [...images.values()]
@@ -57,7 +53,6 @@ export function StarterPack(props: {
backgroundColor: 'black',
color: 'white',
fontFamily: 'Inter',
fontWeight: 700,
}}>
{/* image tiles */}
<div
@@ -138,7 +133,6 @@ export function StarterPack(props: {
fontSize: isLongTitle ? 55 : 65,
display: 'flex',
textAlign: 'center',
textWrap: 'balance',
}}>
{record?.name || 'Starter Pack'}
</div>
-4
View File
@@ -9,7 +9,6 @@ export type ServiceConfig = {
metricsPort: number
version?: string
appviewUrl: string
chatUrl: string
originVerify?: string
}
@@ -18,7 +17,6 @@ export type Environment = {
metricsPort?: number
version?: string
appviewUrl?: string
chatUrl?: string
originVerify?: string
}
@@ -28,7 +26,6 @@ export const readEnv = (): Environment => {
metricsPort: envInt('CARD_METRICS_PORT'),
version: envStr('CARD_VERSION'),
appviewUrl: envStr('CARD_APPVIEW_URL'),
chatUrl: envStr('CARD_CHAT_URL'),
originVerify: envStr('CARD_ORIGIN_VERIFY'),
}
}
@@ -39,7 +36,6 @@ export const envToCfg = (env: Environment): Config => {
metricsPort: env.metricsPort ?? 3001,
version: env.version,
appviewUrl: env.appviewUrl ?? 'https://api.bsky.app',
chatUrl: env.chatUrl ?? 'https://api.bsky.chat',
originVerify: env.originVerify,
}
return {
+3 -28
View File
@@ -3,7 +3,6 @@ import * as path from 'node:path'
import {fileURLToPath} from 'node:url'
import {AtpAgent} from '@atproto/api'
import {type FontWeight} from 'satori'
import {type Config} from './config.js'
@@ -12,15 +11,13 @@ const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
export type AppContextOptions = {
cfg: Config
appviewAgent: AtpAgent
chatAgent: AtpAgent
fonts: {name: string; data: Buffer; weight?: FontWeight}[]
fonts: {name: string; data: Buffer}[]
}
export class AppContext {
cfg: Config
appviewAgent: AtpAgent
chatAgent: AtpAgent
fonts: {name: string; data: Buffer; weight?: FontWeight}[]
fonts: {name: string; data: Buffer}[]
abortController = new AbortController()
private opts: AppContextOptions
@@ -29,46 +26,24 @@ export class AppContext {
this.opts = opts
this.cfg = opts.cfg
this.appviewAgent = opts.appviewAgent
this.chatAgent = opts.chatAgent
this.fonts = opts.fonts
}
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
const chatAgent = new AtpAgent({service: cfg.service.chatUrl})
const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
const fontFiles = readdirSync(fontDirectory)
const fonts = fontFiles.map(file => {
return {
name: getName(file),
name: path.basename(file, path.extname(file)),
data: readFileSync(path.join(fontDirectory, file)),
weight: getWeight(file),
}
})
return new AppContext({
cfg,
appviewAgent,
chatAgent,
fonts,
...overrides,
})
}
}
function getName(file: string): string {
const base = path.basename(file, path.extname(file))
if (base.startsWith('Inter-')) return 'Inter'
return base
}
function getWeight(file: string): FontWeight {
switch (path.basename(file, path.extname(file))) {
case 'Inter-Regular':
return 400
case 'Inter-SemiBold':
return 600
case 'Inter-Bold':
default:
return 700
}
}
-82
View File
@@ -1,82 +0,0 @@
import assert from 'node:assert'
import {ChatBskyGroupDefs} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
import satori from 'satori'
import {
CHAT_INVITE_HEIGHT,
CHAT_INVITE_WIDTH,
ChatInvite,
} from '../components/ChatInvite.js'
import {type AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {loadEmojiAsSvg} from '../util.js'
import {
getImage,
handler,
hideAvatarLabels,
originVerifyMiddleware,
} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/chat-invite/:code',
originVerifyMiddleware(ctx),
handler(async (req, res) => {
const {code} = req.params
let preview: ChatBskyGroupDefs.JoinLinkPreviewView
try {
const result = await ctx.chatAgent.chat.bsky.group.getJoinLinkPreviews({
codes: [code],
})
const found = result.data.joinLinkPreviews[0]
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(found)) {
return res.status(404).end('not found')
}
preview = found
} catch (err) {
httpLogger.warn({err, code}, 'could not fetch chat invite preview')
return res.status(404).end('not found')
}
// Load the owner's avatar (if any, and not labeled).
let ownerImage: Buffer | null = null
const owner = preview.owner
const ownerHasSafeAvatar =
!!owner.avatar && !owner.labels?.some(l => hideAvatarLabels.has(l.val))
if (ownerHasSafeAvatar) {
try {
assert(owner.avatar)
ownerImage = await getImage(owner.avatar)
} catch (err) {
httpLogger.warn(
{err, code, did: owner.did},
'could not fetch owner image',
)
}
}
const svg = await satori(
<ChatInvite preview={preview} ownerImage={ownerImage} />,
{
fonts: ctx.fonts,
height: CHAT_INVITE_HEIGHT,
width: CHAT_INVITE_WIDTH,
loadAdditionalAsset: async (lang, text) => {
if (lang === 'emoji') {
return (await loadEmojiAsSvg(text)) ?? ''
}
return ''
},
},
)
const output = await resvg.renderAsync(svg)
res.statusCode = 200
res.setHeader('content-type', 'image/png')
res.setHeader('cdn-tag', owner.did)
return res.end(output.asPng())
}),
)
}
-2
View File
@@ -2,7 +2,6 @@ import {type Express} from 'express'
import {type AppContext} from '../context.js'
import {default as avatarBubbles} from './avatar-bubbles.js'
import {default as chatInvite} from './chat-invite.js'
import {default as health} from './health.js'
import {default as starterPack} from './starter-pack.js'
@@ -11,7 +10,6 @@ export * from './util.js'
export default function (ctx: AppContext, app: Express) {
app = health(ctx, app) // GET /_health
app = starterPack(ctx, app) // GET /start/:actor/:rkey
app = chatInvite(ctx, app) // GET /chat-invite/:code
app = avatarBubbles(ctx, app) // GET /avatar-bubbles?dids=...
return app
}
+4 -5
View File
@@ -19,7 +19,6 @@ import {
hideAvatarLabels,
originVerifyMiddleware,
} from './util.js'
import * as bsky from '../types/bsky/index.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
@@ -30,9 +29,9 @@ export default function (ctx: AppContext, app: Express) {
const uri = AtUri.make(actor, 'app.bsky.graph.starterpack', rkey)
let starterPack: AppBskyGraphDefs.StarterPackView
try {
const result = await ctx.appviewAgent.app.bsky.graph.getStarterPack({
starterPack: uri.toString(),
})
const result = await ctx.appviewAgent.api.app.bsky.graph.getStarterPack(
{starterPack: uri.toString()},
)
starterPack = result.data.starterPack
} catch (err) {
httpLogger.warn(
@@ -42,7 +41,7 @@ export default function (ctx: AppContext, app: Express) {
return res.status(404).end('not found')
}
const imageEntries = await Promise.all(
([starterPack.creator] as Array<bsky.profile.AnyProfileView>)
[starterPack.creator]
.concat((starterPack.listItemsSample ?? []).map(li => li.subject))
// has avatar
.filter(p => p.avatar)
-50
View File
@@ -1,50 +0,0 @@
import {type ValidationResult} from '@atproto/lexicon'
export * as profile from './profile.js'
export * as starterPack from './starterPack.js'
/**
* Fast type checking without full schema validation, for use with data we
* trust, or for non-critical path use cases. Why? Our SDK's `is*` identity
* utils do not assert the type of the entire object, only the `$type` string.
*
* For full validation of the object schema, use the `validate` export from
* this file.
*
* Usage:
* ```ts
* import * as bsky from '#/types/bsky'
*
* if (bsky.dangerousIsType<AppBskyFeedPost.Record>(item, AppBskyFeedPost.isRecord)) {
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
* }
* ```
*/
export function dangerousIsType<R extends {$type?: string}>(
record: unknown,
identity: <V>(v: V) => v is V & {$type: NonNullable<R['$type']>},
): record is R {
return identity(record)
}
/**
* Fully validates the object schema, which has a performance cost.
*
* For faster checks with data we trust, like that from our app view, use the
* `dangerousIsType` export from this same file.
*
* Usage:
* ```ts
* import * as bsky from '#/types/bsky'
*
* if (bsky.validate(item, AppBskyFeedPost.validateRecord)) {
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
* }
* ```
*/
export function validate<R extends {$type?: string}>(
record: unknown,
validator: (v: unknown) => ValidationResult<R>,
): record is R {
return validator(record).success
}
-10
View File
@@ -1,10 +0,0 @@
import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api'
/**
* Matches any profile view exported by our SDK
*/
export type AnyProfileView =
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileView
| AppBskyActorDefs.ProfileViewDetailed
| ChatBskyActorDefs.ProfileViewBasic
-11
View File
@@ -1,11 +0,0 @@
import {AppBskyGraphDefs} from '@atproto/api'
export const isBasicView = AppBskyGraphDefs.isStarterPackViewBasic
export const isView = AppBskyGraphDefs.isStarterPackView
/**
* Matches any starter pack view exported by our SDK
*/
export type AnyStarterPackView =
| AppBskyGraphDefs.StarterPackViewBasic
| AppBskyGraphDefs.StarterPackView
+1 -1
View File
@@ -10,5 +10,5 @@
"outDir": "dist",
"skipLibCheck": true
},
"include": ["./src/**/*"]
"include": ["./src/index.ts", "./src/bin.ts"]
}
+1739
View File
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
package main
import (
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
// Helpers for extracting Open Graph metadata from post embeds. These feed
// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in
// jsonld.go. og:video has its own path because JSON-LD does not yet emit
// VideoObject (deferred — needs `duration` from the appview).
// videoMeta holds og:video meta tag data.
type videoMeta struct {
URL string
Type string
Width int64
Height int64
HasSize bool
}
// extractVideoMeta returns og:video metadata, or the zero value if there's
// no video embed. Respects embedHidden so og:video and og:image stay in sync.
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
if pv == nil || pv.Embed == nil || embedHidden {
return videoMeta{}
}
var v *appbsky.EmbedVideo_View
if pv.Embed.EmbedVideo_View != nil {
v = pv.Embed.EmbedVideo_View
} else if pv.Embed.EmbedRecordWithMedia_View != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
v = pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
}
if v == nil || v.Playlist == "" {
return videoMeta{}
}
out := videoMeta{
URL: v.Playlist,
Type: "application/vnd.apple.mpegurl",
}
if v.AspectRatio != nil {
out.Width = v.AspectRatio.Width
out.Height = v.AspectRatio.Height
out.HasSize = true
}
return out
}
-69
View File
@@ -1,69 +0,0 @@
package main
import (
"testing"
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
func TestExtractVideoMeta(t *testing.T) {
// Plain post — no video.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc", "hi")
if vm := extractVideoMeta(pv, false); vm.URL != "" {
t.Errorf("non-video post produced video meta: %+v", vm)
}
// Video embed with playlist + aspect ratio.
playlist := "https://video.bsky.app/playlist.m3u8"
pv = makePostView("alice.bsky.social", "did:plc:alice", "abc", "watch")
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedVideo_View: &appbsky.EmbedVideo_View{
Thumbnail: strPtr("https://cdn.bsky.app/thumb@jpg"),
Playlist: playlist,
AspectRatio: &appbsky.EmbedDefs_AspectRatio{Width: 16, Height: 9},
},
}
vm := extractVideoMeta(pv, false)
if vm.URL != playlist {
t.Errorf("URL wrong: %v", vm.URL)
}
if vm.Type != "application/vnd.apple.mpegurl" {
t.Errorf("Type wrong: %v", vm.Type)
}
if !vm.HasSize || vm.Width != 16 || vm.Height != 9 {
t.Errorf("aspect ratio not propagated: %+v", vm)
}
// Hidden embed gate suppresses video too.
if vm := extractVideoMeta(pv, true); vm.URL != "" {
t.Errorf("hidden embed should suppress video meta")
}
// Video inside record-with-media.
pv2 := makePostView("alice.bsky.social", "did:plc:alice", "abc", "quote w/ video")
pv2.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
Media: &appbsky.EmbedRecordWithMedia_View_Media{
EmbedVideo_View: &appbsky.EmbedVideo_View{Playlist: playlist},
},
},
}
vm2 := extractVideoMeta(pv2, false)
if vm2.URL != playlist {
t.Errorf("record-with-media video URL not extracted: %+v", vm2)
}
if vm2.HasSize {
t.Errorf("expected HasSize=false when no aspect ratio")
}
// Video without playlist — skip entirely.
pv3 := makePostView("alice.bsky.social", "did:plc:alice", "abc", "no playlist")
pv3.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedVideo_View: &appbsky.EmbedVideo_View{
Thumbnail: strPtr("https://cdn.bsky.app/thumb@jpg"),
},
}
if vm := extractVideoMeta(pv3, false); vm.URL != "" {
t.Errorf("video without playlist should produce empty meta, got %+v", vm)
}
}
-584
View File
@@ -1,584 +0,0 @@
package main
import (
"encoding/json"
"fmt"
appbsky "github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// schema.org structured-data types emitted on post and profile pages.
// Building these as Go structs (rather than inline JSON in templates) gives
// us correct JSON escaping via encoding/json and a single place to test the
// schema. Optional fields use omitempty so empty values don't reach Google's
// validator as null/empty strings.
const schemaOrgContext = "https://schema.org"
type interactionStat struct {
Type string `json:"@type"`
InteractionType string `json:"interactionType"`
UserInteractionCount int64 `json:"userInteractionCount"`
}
type personOrOrg struct {
Type string `json:"@type"`
Name string `json:"name,omitempty"`
AlternateName string `json:"alternateName,omitempty"`
Identifier string `json:"identifier,omitempty"`
URL string `json:"url,omitempty"`
Description string `json:"description,omitempty"`
Image string `json:"image,omitempty"`
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
AgentInteractionStat []interactionStat `json:"agentInteractionStatistic,omitempty"`
ReviewedBy []*verifier `json:"reviewedBy,omitempty"`
}
// verifier is the schema.org Person shape emitted under Person.reviewedBy.
// Narrower than personOrOrg: just enough to identify the verifying entity.
type verifier struct {
Type string `json:"@type"`
Name string `json:"name,omitempty"`
AlternateName string `json:"alternateName,omitempty"`
Identifier string `json:"identifier,omitempty"`
URL string `json:"url,omitempty"`
}
type sharedContent struct {
Type string `json:"@type"`
URL string `json:"url"`
}
type discussionForumPosting struct {
Context string `json:"@context,omitempty"`
Type string `json:"@type"`
URL string `json:"url,omitempty"`
Identifier string `json:"identifier,omitempty"`
Author *personOrOrg `json:"author,omitempty"`
Text string `json:"text,omitempty"`
Image []string `json:"image,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
CommentCount *int64 `json:"commentCount,omitempty"`
Comment []comment `json:"comment,omitempty"`
IsBasedOn string `json:"isBasedOn,omitempty"`
SharedContent *sharedContent `json:"sharedContent,omitempty"`
}
// comment is the schema.org Comment shape used in
// DiscussionForumPosting.comment[]. The comment property does not accept
// DiscussionForumPosting, so replies map to Comment.
type comment struct {
Type string `json:"@type"`
URL string `json:"url,omitempty"`
Identifier string `json:"identifier,omitempty"`
Author *personOrOrg `json:"author,omitempty"`
Text string `json:"text,omitempty"`
Image []string `json:"image,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
}
type webPage struct {
Context string `json:"@context"`
Type string `json:"@type"`
URL string `json:"url,omitempty"`
MainEntity discussionForumPosting `json:"mainEntity"`
}
type profilePage struct {
Context string `json:"@context"`
Type string `json:"@type"`
DateCreated string `json:"dateCreated,omitempty"`
MainEntity *personOrOrg `json:"mainEntity"`
HasPart []discussionForumPosting `json:"hasPart,omitempty"`
}
// maxComments caps DiscussionForumPosting.comment[] to keep SSR HTML small.
const maxComments = 10
// maxRecentPosts caps ProfilePage.hasPart[].
const maxRecentPosts = 10
// maxReviewedBy caps Person.reviewedBy entries.
const maxReviewedBy = 10
// authorFeedFetchLimit oversamples getAuthorFeed because posts_no_replies
// still returns reposts; we drop those client-side. 3x is a safe margin.
const authorFeedFetchLimit = 3 * maxRecentPosts
// bskyPostURL returns the canonical handle-form post URL, or "" if handle
// or rkey is unusable.
func bskyPostURL(handle, rkey string) string {
if handle == "" || handle == "handle.invalid" || rkey == "" {
return ""
}
return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, rkey)
}
// bskyPostURLFromATURI is bskyPostURL for callers holding an at-uri.
func bskyPostURLFromATURI(handle, atURI string) string {
parsed, err := syntax.ParseATURI(atURI)
if err != nil {
return ""
}
return bskyPostURL(handle, parsed.RecordKey().String())
}
// bskyProfileURL returns the canonical handle-form profile URL, or "" if
// the handle is unusable.
func bskyProfileURL(handle string) string {
if handle == "" || handle == "handle.invalid" {
return ""
}
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
}
// extractPostMedia returns thumbnail URLs for the post's image, gallery,
// or video embed, byte-identical to what we put in og:image. Callers
// derive thumbnailUrl from urls[0].
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
if pv == nil || pv.Embed == nil || embedHidden {
return nil
}
if pv.Embed.EmbedImages_View != nil {
return imageThumbs(pv.Embed.EmbedImages_View.Images)
}
if pv.Embed.EmbedGallery_View != nil {
return galleryThumbs(pv.Embed.EmbedGallery_View.Items)
}
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
}
if pv.Embed.EmbedRecordWithMedia_View != nil && pv.Embed.EmbedRecordWithMedia_View.Media != nil {
media := pv.Embed.EmbedRecordWithMedia_View.Media
if media.EmbedImages_View != nil {
return imageThumbs(media.EmbedImages_View.Images)
}
if media.EmbedGallery_View != nil {
return galleryThumbs(media.EmbedGallery_View.Items)
}
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
return []string{*media.EmbedVideo_View.Thumbnail}
}
}
return nil
}
// imageThumbs returns the thumb URLs, or nil if empty.
func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
if len(images) == 0 {
return nil
}
urls := make([]string, 0, len(images))
for _, img := range images {
urls = append(urls, img.Thumb)
}
return urls
}
// galleryThumbs returns the thumbnail URLs of image items in a gallery
// embed, or nil if empty. Items_Elem is a union; non-image variants and
// nil entries are skipped so future gallery item types don't break SEO
// extraction. Empty Thumbnail strings are also skipped to avoid emitting
// <meta property="og:image" content=""> if the appview ever returns one.
func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
if len(items) == 0 {
return nil
}
urls := make([]string, 0, len(items))
for _, item := range items {
if item == nil || item.EmbedGallery_ViewImage == nil {
continue
}
if item.EmbedGallery_ViewImage.Thumbnail == "" {
continue
}
urls = append(urls, item.EmbedGallery_ViewImage.Thumbnail)
}
if len(urls) == 0 {
return nil
}
return urls
}
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
// if the embed is blocked / not-found / detached / a non-post record.
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
if pv == nil || pv.Embed == nil {
return ""
}
var rec *appbsky.EmbedRecord_View
if pv.Embed.EmbedRecord_View != nil {
rec = pv.Embed.EmbedRecord_View
} else if pv.Embed.EmbedRecordWithMedia_View != nil {
rec = pv.Embed.EmbedRecordWithMedia_View.Record
}
if rec == nil || rec.Record == nil {
return ""
}
vr := rec.Record.EmbedRecord_ViewRecord
if vr == nil || vr.Author == nil {
return ""
}
return bskyPostURLFromATURI(vr.Author.Handle, vr.Uri)
}
// extractSharedContentURL returns the URL of an external link embed (also
// from the media slot of a record-with-media embed).
func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
if pv == nil || pv.Embed == nil {
return ""
}
if pv.Embed.EmbedExternal_View != nil && pv.Embed.EmbedExternal_View.External != nil {
return pv.Embed.EmbedExternal_View.External.Uri
}
if pv.Embed.EmbedRecordWithMedia_View != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View.External != nil {
return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View.External.Uri
}
return ""
}
// buildAuthor constructs a Person. Organization classification for
// custom-domain accounts is a future enhancement.
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
if author == nil {
return nil
}
p := &personOrOrg{
Type: "Person",
}
if author.Did != "" {
p.Identifier = author.Did
}
if author.DisplayName != nil && *author.DisplayName != "" {
p.Name = *author.DisplayName
if author.Handle != "" {
p.AlternateName = "@" + author.Handle
}
} else if author.Handle != "" {
p.Name = "@" + author.Handle
}
if url := bskyProfileURL(author.Handle); url != "" {
p.URL = url
}
return p
}
// buildReviewedBy maps a profile's verifications to Person entries for
// Person.reviewedBy. Skips !IsValid and missing Issuer; caps at
// maxReviewedBy. URL falls back to DID-form when the verifier's handle is
// missing or "handle.invalid".
func buildReviewedBy(state *appbsky.ActorDefs_VerificationState) []*verifier {
if state == nil || len(state.Verifications) == 0 {
return nil
}
var out []*verifier
for _, v := range state.Verifications {
if v == nil || !v.IsValid || v.Issuer == "" {
continue
}
if len(out) >= maxReviewedBy {
break
}
entry := &verifier{
Type: "Person",
Identifier: v.Issuer,
}
var handle string
if v.IssuerHandle != nil {
handle = *v.IssuerHandle
}
if v.IssuerDisplayName != nil && *v.IssuerDisplayName != "" {
entry.Name = *v.IssuerDisplayName
if handle != "" && handle != "handle.invalid" {
entry.AlternateName = "@" + handle
}
} else if handle != "" && handle != "handle.invalid" {
entry.Name = "@" + handle
}
if url := bskyProfileURL(handle); url != "" {
entry.URL = url
} else {
entry.URL = "https://bsky.app/profile/" + v.Issuer
}
out = append(out, entry)
}
return out
}
// postHasHideLabel reports whether the post-view or self-labels include
// any non-negated label in labelSet. Self-labels have no negation.
func postHasHideLabel(pv *appbsky.FeedDefs_PostView, labelSet map[string]bool) bool {
if pv == nil {
return false
}
for _, label := range pv.Labels {
isNeg := label.Neg != nil && *label.Neg
if labelSet[label.Val] && !isNeg {
return true
}
}
if pv.Record != nil {
if rec, ok := pv.Record.Val.(*appbsky.FeedPost); ok {
if rec.Labels != nil && rec.Labels.LabelDefs_SelfLabels != nil {
for _, label := range rec.Labels.LabelDefs_SelfLabels.Values {
if labelSet[label.Val] {
return true
}
}
}
}
}
return false
}
// postEmbedHidden reports whether any post-view label or self-label asks
// embeds to be omitted. WebPost uses this so og:image and JSON-LD media
// suppression stay in sync.
func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) bool {
return postHasHideLabel(pv, hideLabels)
}
// postRecordText returns the post's expanded text, or "" if the record is
// missing or malformed.
func postRecordText(pv *appbsky.FeedDefs_PostView) string {
if pv == nil || pv.Record == nil {
return ""
}
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
if !ok {
return ""
}
return ExpandPostText(rec)
}
// buildPostStats returns the like / comment / share interaction triple.
// commentCount is emitted separately on DiscussionForumPosting.
func buildPostStats(pv *appbsky.FeedDefs_PostView) []interactionStat {
if pv == nil {
return nil
}
deref := func(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
return []interactionStat{
{Type: "InteractionCounter", InteractionType: "https://schema.org/LikeAction", UserInteractionCount: deref(pv.LikeCount)},
{Type: "InteractionCounter", InteractionType: "https://schema.org/CommentAction", UserInteractionCount: deref(pv.ReplyCount)},
{Type: "InteractionCounter", InteractionType: "https://schema.org/ShareAction", UserInteractionCount: deref(pv.RepostCount) + deref(pv.QuoteCount)},
}
}
// buildPostNode constructs a DiscussionForumPosting in nested form (no
// envelope, no @context). Used for top-level posts and for entries in
// hasPart / comment arrays. Returns the zero value if pv or pv.Author is
// nil; callers should treat that as "skip". Replies whose own labels match
// hideLabels or hideReplyLabels are dropped from comment[].
func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, hideLabels, hideReplyLabels map[string]bool) discussionForumPosting {
if pv == nil || pv.Author == nil {
return discussionForumPosting{}
}
embedHidden := postEmbedHidden(pv, hideLabels)
images := extractPostMedia(pv, embedHidden)
var thumb string
if len(images) > 0 {
thumb = images[0]
}
node := discussionForumPosting{
Type: "DiscussionForumPosting",
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
Identifier: pv.Uri,
Author: buildAuthor(pv.Author),
Text: postRecordText(pv),
Image: images,
ThumbnailURL: thumb,
DatePublished: pv.IndexedAt,
InteractionStat: buildPostStats(pv),
}
// Surface verifications on the post author only; replies do not.
if node.Author != nil {
if rb := buildReviewedBy(pv.Author.Verification); len(rb) > 0 {
node.Author.ReviewedBy = rb
}
}
if pv.ReplyCount != nil {
node.CommentCount = pv.ReplyCount
} else {
zero := int64(0)
node.CommentCount = &zero
}
if !embedHidden {
if quoted := extractQuotedPostURL(pv); quoted != "" {
node.IsBasedOn = quoted
}
if shared := extractSharedContentURL(pv); shared != "" {
node.SharedContent = &sharedContent{Type: "WebPage", URL: shared}
}
}
for _, r := range replies {
if r == nil || r.FeedDefs_ThreadViewPost == nil || r.FeedDefs_ThreadViewPost.Post == nil {
continue
}
if len(node.Comment) >= maxComments {
break
}
replyPV := r.FeedDefs_ThreadViewPost.Post
// Drop labeled replies entirely so abusive/spam text isn't surfaced
// into the parent post's structured data.
if postHasHideLabel(replyPV, hideReplyLabels) || postHasHideLabel(replyPV, hideLabels) {
continue
}
reply := buildReplyNode(replyPV, hideLabels)
if reply.Type == "" {
continue
}
node.Comment = append(node.Comment, reply)
}
return node
}
// buildReplyNode builds a schema.org Comment for a reply. Returns the
// zero value if pv or pv.Author is nil.
func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) comment {
if pv == nil || pv.Author == nil {
return comment{}
}
embedHidden := postEmbedHidden(pv, hideLabels)
images := extractPostMedia(pv, embedHidden)
var thumb string
if len(images) > 0 {
thumb = images[0]
}
return comment{
Type: "Comment",
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
Identifier: pv.Uri,
Author: buildAuthor(pv.Author),
Text: postRecordText(pv),
Image: images,
ThumbnailURL: thumb,
DatePublished: pv.IndexedAt,
}
}
// buildPostJSONLD marshals the WebPage envelope wrapping a
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
// (as a fallback) mainEntity.url so they always agree.
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
if pv == nil || pv.Author == nil {
return "", fmt.Errorf("nil post view or author")
}
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
// mainEntity.url is empty when the author handle is unusable; fall back
// to canonicalURL so it agrees with envelope.url.
if node.URL == "" {
node.URL = canonicalURL
}
envelope := webPage{
Context: schemaOrgContext,
Type: "WebPage",
URL: canonicalURL,
MainEntity: node,
}
b, err := json.Marshal(envelope)
if err != nil {
return "", err
}
return string(b), nil
}
// buildProfileJSONLD marshals a ProfilePage (with hasPart recent posts).
// Recent posts whose own labels match hideLabels or hideReplyLabels are
// dropped from hasPart, mirroring the gating applied to comment[].
func buildProfileJSONLD(pv *appbsky.ActorDefs_ProfileViewDetailed, recentPosts []*appbsky.FeedDefs_PostView, hideLabels, hideReplyLabels map[string]bool) (string, error) {
if pv == nil {
return "", fmt.Errorf("nil profile view")
}
person := &personOrOrg{
Type: "Person",
Identifier: pv.Did,
}
if pv.DisplayName != nil && *pv.DisplayName != "" {
person.Name = *pv.DisplayName
person.AlternateName = "@" + pv.Handle
} else {
person.Name = "@" + pv.Handle
}
if pv.Description != nil {
person.Description = *pv.Description
}
if pv.Avatar != nil {
person.Image = *pv.Avatar
}
deref := func(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
person.InteractionStat = []interactionStat{
{Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowersCount)},
}
person.AgentInteractionStat = []interactionStat{
{Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowsCount)},
{Type: "InteractionCounter", InteractionType: "https://schema.org/WriteAction", UserInteractionCount: deref(pv.PostsCount)},
}
if rb := buildReviewedBy(pv.Verification); len(rb) > 0 {
person.ReviewedBy = rb
}
page := profilePage{
Context: schemaOrgContext,
Type: "ProfilePage",
MainEntity: person,
}
if pv.CreatedAt != nil {
page.DateCreated = *pv.CreatedAt
}
for _, rp := range recentPosts {
if rp == nil {
continue
}
if len(page.HasPart) >= maxRecentPosts {
break
}
// Drop labeled posts entirely so flagged content isn't surfaced
// into the profile's structured data.
if postHasHideLabel(rp, hideReplyLabels) || postHasHideLabel(rp, hideLabels) {
continue
}
// Recent posts go in nested form. No replies are passed, so the
// reply-label set is irrelevant; pass nil.
node := buildPostNode(rp, nil, hideLabels, nil)
if node.Type == "" {
continue
}
page.HasPart = append(page.HasPart, node)
}
b, err := json.Marshal(page)
if err != nil {
return "", err
}
return string(b), nil
}
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
package main
import (
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
// Helpers for inspecting profile / post labels.
// profileRequiresAuth reports whether the profile has self-applied the
// !no-unauthenticated label. SSR responses for these profiles omit post
// content beyond minimal identity.
func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
if pv == nil {
return false
}
for _, label := range pv.Labels {
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
return true
}
}
return false
}
-79
View File
@@ -1,79 +0,0 @@
package main
import (
"testing"
comatprototypes "github.com/bluesky-social/indigo/api/atproto"
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
func TestProfileRequiresAuth(t *testing.T) {
negTrue := true
tests := []struct {
name string
pv *appbsky.ActorDefs_ProfileViewDetailed
want bool
}{
{
name: "nil profile",
pv: nil,
want: false,
},
{
name: "no labels",
pv: &appbsky.ActorDefs_ProfileViewDetailed{Did: "did:plc:alice"},
want: false,
},
{
name: "self-applied !no-unauthenticated",
pv: &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice",
Labels: []*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "!no-unauthenticated"},
},
},
want: true,
},
{
name: "label from a different src does not gate",
pv: &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice",
Labels: []*comatprototypes.LabelDefs_Label{
{Src: "did:plc:labeler", Val: "!no-unauthenticated"},
},
},
want: false,
},
{
name: "different label value does not gate",
pv: &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice",
Labels: []*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "spam"},
},
},
want: false,
},
{
// Negation isn't honored — matches prior inline behavior in
// WebPost / WebProfile / WebProfileRSS.
name: "negated label still triggers (matches prior behavior)",
pv: &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice",
Labels: []*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "!no-unauthenticated", Neg: &negTrue},
},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := profileRequiresAuth(tt.pv); got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
-6
View File
@@ -46,12 +46,6 @@ func run(args []string) {
Required: false,
EnvVars: []string{"OGCARD_HOST"},
},
&cli.StringFlag{
Name: "chat-host",
Usage: "scheme, hostname, and port of chat appview",
Required: false,
EnvVars: []string{"CHAT_HOST"},
},
&cli.StringFlag{
Name: "http-address",
Usage: "Specify the local IP/port to bind to",
-299
View File
@@ -1,299 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"regexp"
"strings"
"testing"
"github.com/bluesky-social/social-app/bskyweb"
"github.com/flosch/pongo2/v6"
)
// renderTemplate executes a template using the live renderer and returns
// the output. base.html includes templates/scripts.html, which is generated
// by `yarn build-web`; skip the test if it's missing.
func renderTemplate(t *testing.T, name string, ctx pongo2.Context) string {
t.Helper()
if _, err := bskyweb.TemplateFS.ReadFile("templates/scripts.html"); err != nil {
t.Skip("templates/scripts.html not present (run yarn build-web first)")
}
r := NewRenderer("templates/", &bskyweb.TemplateFS, false)
tmpl, err := r.TemplateSet.FromCache(name)
if err != nil {
t.Fatalf("template load %q: %v", name, err)
}
var buf bytes.Buffer
if err := tmpl.ExecuteWriter(ctx, &buf); err != nil {
t.Fatalf("template execute %q: %v", name, err)
}
return buf.String()
}
var jsonLDRe = regexp.MustCompile(`(?s)<script type="application/ld\+json">(.*?)</script>`)
// extractJSONLD pulls out the body of the application/ld+json script tag.
func extractJSONLD(t *testing.T, html string) string {
t.Helper()
m := jsonLDRe.FindStringSubmatch(html)
if m == nil {
t.Fatalf("no application/ld+json script tag in rendered HTML:\n%s", html)
}
return strings.TrimSpace(m[1])
}
func TestRenderBase_NoindexMeta(t *testing.T) {
html := renderTemplate(t, "base.html", pongo2.Context{"noindex": true, "nofollow": true})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("expected combined noindex,nofollow meta; got:\n%s", html)
}
}
func TestRenderPost_EmitsJSONLD(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
})
body := extractJSONLD(t, html)
var parsed map[string]any
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
}
if parsed["@type"] != "WebPage" {
t.Errorf("expected WebPage envelope, got %v", parsed["@type"])
}
// Canonical link should be the handle-form URL.
if !strings.Contains(html, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
t.Errorf("canonical link missing or wrong:\n%s", html)
}
}
func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
"imgThumbUrls": []string{thumb1, thumb2},
})
// og:image and JSON-LD image[] must be byte-identical.
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
t.Errorf("og:image[0] not found in rendered HTML")
}
body := extractJSONLD(t, html)
var parsed map[string]any
_ = json.Unmarshal([]byte(body), &parsed)
main := parsed["mainEntity"].(map[string]any)
imgs := main["image"].([]any)
if imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
t.Errorf("JSON-LD image strings drifted from og:image; image[0]=%v thumbnailUrl=%v",
imgs[0], main["thumbnailUrl"])
}
}
// Gallery posts must hit the same og:image / JSON-LD image[] byte-equality
// contract that legacy images posts do. Regression guard for the
// app.bsky.embed.gallery extraction path.
func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
"imgThumbUrls": thumbs,
})
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
t.Errorf("og:image[0] not found in rendered HTML for gallery post")
}
if !strings.Contains(html, `<meta property="og:image" content="`+thumb2+`">`) {
t.Errorf("og:image[1] not found in rendered HTML for gallery post")
}
body := extractJSONLD(t, html)
var parsed map[string]any
_ = json.Unmarshal([]byte(body), &parsed)
main := parsed["mainEntity"].(map[string]any)
imgs := main["image"].([]any)
if len(imgs) != 2 || imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
t.Errorf("JSON-LD image strings drifted from og:image for gallery; image=%v thumbnailUrl=%v",
imgs, main["thumbnailUrl"])
}
}
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
"postJSONLD": ld,
})
if !strings.Contains(html, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
t.Errorf("expected canonicalize_url filter to strip query; got:\n%s", html)
}
}
func TestRenderProfile_EmitsJSONLD(t *testing.T) {
pv := newProfileViewDetailed()
ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
html := renderTemplate(t, "profile.html", pongo2.Context{
"profileView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social",
"profileJSONLD": ld,
})
body := extractJSONLD(t, html)
var parsed map[string]any
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
}
if parsed["@type"] != "ProfilePage" {
t.Errorf("expected ProfilePage, got %v", parsed["@type"])
}
}
// Regression: auth-required profiles must still emit ProfilePage JSON-LD
// (without hasPart). Previously regressed when WebProfile short-circuited
// before buildProfileJSONLD.
func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
pv := newProfileViewDetailed()
ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
html := renderTemplate(t, "profile.html", pongo2.Context{
"profileView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social",
"requiresAuth": true,
"profileJSONLD": ld,
})
body := extractJSONLD(t, html)
var parsed map[string]any
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
}
if parsed["@type"] != "ProfilePage" {
t.Errorf("expected ProfilePage, got %v", parsed["@type"])
}
if _, present := parsed["hasPart"]; present {
t.Errorf("auth-required profile should not have hasPart")
}
}
// og:url and <link rel="canonical"> must emit the same URL.
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/did:plc:alice/post/abc123",
"canonicalURL": canonical,
"postJSONLD": ld,
})
if !strings.Contains(html, `<meta property="og:url" content="`+canonical+`">`) {
t.Errorf("og:url should equal canonical URL when set; got:\n%s", html)
}
if !strings.Contains(html, `<link rel="canonical" href="`+canonical+`" />`) {
t.Errorf("canonical link missing or wrong:\n%s", html)
}
// DID-form request URI must not leak into og:url.
if strings.Contains(html, `<meta property="og:url" content="https://bsky.app/profile/did:plc:alice/post/abc123">`) {
t.Errorf("og:url should not echo DID-form request URI when canonical is set")
}
}
// og:video must emit even when there is no thumbnail. Previously the
// {% if videoUrl %} block was nested inside {% if imgThumbUrls %}, so a
// video without a thumbnail dropped og:video entirely.
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
videoURL := "https://video.bsky.app/v.m3u8"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
"videoUrl": videoURL,
"videoType": "application/x-mpegURL",
})
if !strings.Contains(html, `<meta property="og:video" content="`+videoURL+`">`) {
t.Errorf("og:video should emit even without imgThumbUrls; got:\n%s", html)
}
if !strings.Contains(html, `<meta property="og:video:type" content="application/x-mpegURL">`) {
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
}
}
// Auth-required posts must emit noindex,nofollow so the stub page (no body
// text, no comments) is not indexed.
func TestRenderPost_AuthRequiredNoindex(t *testing.T) {
html := renderTemplate(t, "post.html", pongo2.Context{
"requiresAuth": true,
"profileHandle": "alice.bsky.social",
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"noindex": true,
"nofollow": true,
})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("auth-required post should emit noindex,nofollow; got:\n%s", html)
}
}
// Auth-required profiles must emit noindex,nofollow.
func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
pv := newProfileViewDetailed()
html := renderTemplate(t, "profile.html", pongo2.Context{
"profileView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social",
"requiresAuth": true,
"noindex": true,
"nofollow": true,
})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("auth-required profile should emit noindex,nofollow; got:\n%s", html)
}
}
// Public posts must NOT emit a robots meta tag. Guards against an accidental
// flip of the noindex flag for indexable pages.
func TestRenderPost_PublicNoNoindex(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
})
if strings.Contains(html, `<meta name="robots"`) {
t.Errorf("public post should not emit robots meta; got:\n%s", html)
}
}
+8 -4
View File
@@ -58,8 +58,10 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %s", handle))
}
if profileRequiresAuth(pv) {
return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", handle))
for _, label := range pv.Labels {
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", handle))
}
}
return c.Redirect(http.StatusFound, fmt.Sprintf("/profile/%s/rss", pv.Did))
}
@@ -74,8 +76,10 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %s", did))
}
if profileRequiresAuth(pv) {
return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", did))
for _, label := range pv.Labels {
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", did))
}
}
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, did.String(), "", "posts_no_replies", false, 30)
+145 -240
View File
@@ -17,13 +17,11 @@ import (
"net/url"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
appbsky "github.com/bluesky-social/indigo/api/bsky"
chat "github.com/bluesky-social/indigo/api/chat"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/util/cliutil"
"github.com/bluesky-social/indigo/xrpc"
@@ -39,11 +37,10 @@ import (
)
type Server struct {
echo *echo.Echo
httpd *http.Server
xrpcc *xrpc.Client
chatXrpcc *xrpc.Client
cfg *Config
echo *echo.Echo
httpd *http.Server
xrpcc *xrpc.Client
cfg *Config
ipccClient http.Client
@@ -57,7 +54,6 @@ type Config struct {
debug bool
httpAddress string
appviewHost string
chatHost string
ogcardHost string
linkHost string
ipccHost string
@@ -68,7 +64,6 @@ func serve(cctx *cli.Context) error {
debug := cctx.Bool("debug")
httpAddress := cctx.String("http-address")
appviewHost := cctx.String("appview-host")
chatHost := cctx.String("chat-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
ipccHost := cctx.String("ipcc-host")
@@ -88,15 +83,6 @@ func serve(cctx *cli.Context) error {
Host: appviewHost,
}
// optional client for the chat appview, used by /chat/<code> for OG previews.
var chatXrpcc *xrpc.Client
if chatHost != "" {
chatXrpcc = &xrpc.Client{
Client: cliutil.NewHttpClient(),
Host: chatHost,
}
}
// httpd
var (
httpTimeout = 2 * time.Minute
@@ -120,14 +106,12 @@ func serve(cctx *cli.Context) error {
// server
//
server := &Server{
echo: e,
xrpcc: xrpcc,
chatXrpcc: chatXrpcc,
echo: e,
xrpcc: xrpcc,
cfg: &Config{
debug: debug,
httpAddress: httpAddress,
appviewHost: appviewHost,
chatHost: chatHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
ipccHost: ipccHost,
@@ -296,50 +280,57 @@ func serve(cctx *cli.Context) error {
// generic routes
e.GET("/hashtag/:tag", server.WebGeneric)
e.GET("/topic/:topic", server.WebGeneric)
e.GET("/search", server.WebGenericNoindex)
e.GET("/feeds", server.WebGenericNoindex)
e.GET("/notifications", server.WebGenericNoindex)
e.GET("/notifications/settings", server.WebGenericNoindex)
e.GET("/notifications/activity", server.WebGenericNoindex)
e.GET("/lists", server.WebGenericNoindex)
e.GET("/moderation", server.WebGenericNoindex)
e.GET("/moderation/modlists", server.WebGenericNoindex)
e.GET("/moderation/muted-accounts", server.WebGenericNoindex)
e.GET("/moderation/blocked-accounts", server.WebGenericNoindex)
e.GET("/moderation/verification-settings", server.WebGenericNoindex)
e.GET("/settings", server.WebGenericNoindex)
e.GET("/settings/language", server.WebGenericNoindex)
e.GET("/settings/app-passwords", server.WebGenericNoindex)
e.GET("/settings/following-feed", server.WebGenericNoindex)
e.GET("/settings/saved-feeds", server.WebGenericNoindex)
e.GET("/settings/threads", server.WebGenericNoindex)
e.GET("/settings/external-embeds", server.WebGenericNoindex)
e.GET("/settings/accessibility", server.WebGenericNoindex)
e.GET("/settings/appearance", server.WebGenericNoindex)
e.GET("/settings/account", server.WebGenericNoindex)
e.GET("/settings/automation-label", server.WebGenericNoindex)
e.GET("/settings/privacy-and-security", server.WebGenericNoindex)
e.GET("/settings/privacy-and-security/activity", server.WebGenericNoindex)
e.GET("/settings/content-and-media", server.WebGenericNoindex)
e.GET("/settings/interests", server.WebGenericNoindex)
e.GET("/settings/about", server.WebGenericNoindex)
e.GET("/settings/notifications", server.WebGenericNoindex)
e.GET("/sys/debug", server.WebGenericNoindex)
e.GET("/sys/debug-mod", server.WebGenericNoindex)
e.GET("/sys/log", server.WebGenericNoindex)
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
e.GET("/notifications/activity", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
e.GET("/moderation/muted-accounts", server.WebGeneric)
e.GET("/moderation/blocked-accounts", server.WebGeneric)
e.GET("/moderation/verification-settings", server.WebGeneric)
e.GET("/settings", server.WebGeneric)
e.GET("/settings/language", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric)
e.GET("/settings/following-feed", server.WebGeneric)
e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/settings/accessibility", server.WebGeneric)
e.GET("/settings/appearance", server.WebGeneric)
e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/automation-label", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric)
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric)
e.GET("/settings/interests", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric)
e.GET("/settings/notifications", server.WebGeneric)
e.GET("/settings/notifications/replies", server.WebGeneric)
e.GET("/settings/notifications/mentions", server.WebGeneric)
e.GET("/settings/notifications/quotes", server.WebGeneric)
e.GET("/settings/notifications/likes", server.WebGeneric)
e.GET("/settings/notifications/reposts", server.WebGeneric)
e.GET("/settings/notifications/new-followers", server.WebGeneric)
e.GET("/settings/notifications/likes-on-reposts", server.WebGeneric)
e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric)
e.GET("/settings/notifications/activity", server.WebGeneric)
e.GET("/settings/notifications/miscellaneous", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
e.GET("/support", server.WebGeneric)
e.GET("/support/privacy", server.WebGeneric)
e.GET("/support/tos", server.WebGeneric)
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGenericNoindexNofollow)
e.GET("/intent/verify-email", server.WebGenericNoindexNofollow)
e.GET("/intent/age-assurance", server.WebGenericNoindexNofollow)
e.GET("/messages", server.WebGenericNoindex)
e.GET("/messages/inbox", server.WebGenericNoindex)
e.GET("/messages/:conversation", server.WebGenericNoindex)
e.GET("/messages/:conversation/settings", server.WebGenericNoindex)
e.GET("/messages/:conversation/requests", server.WebGenericNoindex)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/intent/verify-email", server.WebGeneric)
e.GET("/intent/age-assurance", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
// profile endpoints; only first populates info
e.GET("/profile/:handleOrDID", server.WebProfile)
@@ -363,14 +354,11 @@ func serve(cctx *cli.Context) error {
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/starter-pack-short/:code", server.WebGenericNoindex)
e.GET("/starter-pack-short/:code", server.WebGeneric)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// chat invites
e.GET("/chat/:code", server.WebChatInvite)
// bookmarks
e.GET("/saved", server.WebGenericNoindex)
e.GET("/saved", server.WebGeneric)
// ipcc
e.GET("/ipcc", server.WebIpCC)
@@ -437,8 +425,6 @@ func (srv *Server) NewTemplateContext() pongo2.Context {
return pongo2.Context{
"staticCDNHost": srv.cfg.staticCDNHost,
"favicon": fmt.Sprintf("%s/static/favicon.png", srv.cfg.staticCDNHost),
"noindex": false,
"nofollow": false,
}
}
@@ -491,38 +477,10 @@ func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
)
}
// renderOptions controls per-request rendering flags for the generic web handler.
type renderOptions struct {
noindex bool
nofollow bool
}
// webGeneric returns a handler that renders the base SPA shell with the given
// render options applied to the template context.
func (srv *Server) webGeneric(c echo.Context, o renderOptions) error {
data := srv.NewTemplateContext()
data["noindex"] = o.noindex
data["nofollow"] = o.nofollow
return c.Render(http.StatusOK, "base.html", data)
}
// handler for endpoint that have no specific server-side handling
func (srv *Server) WebGeneric(c echo.Context) error {
return srv.webGeneric(c, renderOptions{})
}
// handler for routes that should not be indexed by search engines
// (e.g. auth-only user-state surfaces, internal/debug pages, action/intent dispatch URLs, search results)
func (srv *Server) WebGenericNoindex(c echo.Context) error {
return srv.webGeneric(c, renderOptions{noindex: true})
}
// handler for action/intent dispatch URLs (e.g. /intent/compose). These accept
// arbitrary query parameters from arbitrary third-party referrers, so we treat
// them as link-graph dead-ends in addition to noindex. Anything legitimately
// reachable from a hydrated intent page is also reachable via its canonical URL.
func (srv *Server) WebGenericNoindexNofollow(c echo.Context) error {
return srv.webGeneric(c, renderOptions{noindex: true, nofollow: true})
data := srv.NewTemplateContext()
return c.Render(http.StatusOK, "base.html", data)
}
func (srv *Server) WebHome(c echo.Context) error {
@@ -542,20 +500,6 @@ var hideEmbedLabels = map[string]bool{
"sensitive": true,
}
// Replies surfaced into a post's JSON-LD comment[] are dropped entirely when
// any of these labels are present (in addition to hideEmbedLabels). Targets
// abuse/spam in third-party reply text, since reply text would otherwise be
// emitted into the parent post's structured data.
var hideReplyLabels = map[string]bool{
"!hide": true,
"!warn": true,
"porn": true,
"sexual": true,
"nudity": true,
"graphic-media": true,
"spam": true,
}
func (srv *Server) WebPost(c echo.Context) error {
ctx := c.Request().Context()
data := srv.NewTemplateContext()
@@ -580,25 +524,18 @@ func (srv *Server) WebPost(c echo.Context) error {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
return c.Render(http.StatusOK, "post.html", data)
}
unauthedViewingOkay := !profileRequiresAuth(pv)
unauthedViewingOkay := true
for _, label := range pv.Labels {
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
unauthedViewingOkay = false
}
}
req := c.Request()
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
// Always prefer the handle-form URL so JSON-LD `url` and
// <link rel="canonical"> match. Falls back to requestURI when the
// handle is unusable (template strips query/fragment).
canonicalURL := bskyPostURL(pv.Handle, rkey.String())
if !unauthedViewingOkay {
// Provide minimal OpenGraph data for auth-required posts
data["requestURI"] = requestURI
if canonicalURL != "" {
data["canonicalURL"] = canonicalURL
}
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
data["profileHandle"] = pv.Handle
if pv.DisplayName != nil {
data["profileDisplayName"] = *pv.DisplayName
@@ -614,44 +551,81 @@ func (srv *Server) WebPost(c echo.Context) error {
return c.Render(http.StatusOK, "post.html", data)
}
threadView := tpv.Thread.FeedDefs_ThreadViewPost
if threadView == nil || threadView.Post == nil {
return c.Render(http.StatusOK, "post.html", data)
}
postView := threadView.Post
postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
data["postView"] = postView
data["requestURI"] = requestURI
if canonicalURL != "" {
data["canonicalURL"] = canonicalURL
}
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
// Share extraction helpers with jsonld.go so og:image and JSON-LD
// image[] are byte-identical (per Google's requirement).
isEmbedHidden := postEmbedHidden(postView, hideEmbedLabels)
data["postText"] = postRecordText(postView)
if thumbs := extractPostMedia(postView, isEmbedHidden); len(thumbs) > 0 {
data["imgThumbUrls"] = thumbs
}
if vm := extractVideoMeta(postView, isEmbedHidden); vm.URL != "" {
data["videoUrl"] = vm.URL
data["videoType"] = vm.Type
if vm.HasSize {
data["videoWidth"] = vm.Width
data["videoHeight"] = vm.Height
// If any undesirable labels are set, the embed will not be included in
// metadata
isEmbedHidden := false
for _, label := range postView.Labels {
isNeg := label.Neg != nil && *label.Neg
if hideEmbedLabels[label.Val] && !isNeg {
isEmbedHidden = true
break
}
}
// Build JSON-LD. Fall back to requestURI when handle is unusable so the
// envelope url is never empty.
jsonldURL := canonicalURL
if jsonldURL == "" {
jsonldURL = requestURI
if postView.Record != nil {
postRecord, ok := postView.Record.Val.(*appbsky.FeedPost)
if ok {
data["postText"] = ExpandPostText(postRecord)
if !isEmbedHidden && postRecord.Labels != nil && postRecord.Labels.LabelDefs_SelfLabels != nil {
for _, label := range postRecord.Labels.LabelDefs_SelfLabels.Values {
if hideEmbedLabels[label.Val] {
isEmbedHidden = true
break
}
}
}
}
}
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
data["postJSONLD"] = jsonld
} else {
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
if postView.Embed != nil && !isEmbedHidden {
hasImages := postView.Embed.EmbedImages_View != nil
hasVideo := postView.Embed.EmbedVideo_View != nil
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil
hasMediaImages := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
hasMediaVideo := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil
if hasImages {
var thumbUrls []string
for i := range postView.Embed.EmbedImages_View.Images {
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} else if hasVideo {
if postView.Embed.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
}
if postView.Embed.EmbedVideo_View.Playlist != "" {
data["videoUrl"] = postView.Embed.EmbedVideo_View.Playlist
data["videoType"] = "application/vnd.apple.mpegurl"
if postView.Embed.EmbedVideo_View.AspectRatio != nil {
data["videoWidth"] = postView.Embed.EmbedVideo_View.AspectRatio.Width
data["videoHeight"] = postView.Embed.EmbedVideo_View.AspectRatio.Height
}
}
} else if hasMediaImages {
var thumbUrls []string
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} else if hasMediaVideo {
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail}
}
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist != "" {
data["videoUrl"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist
data["videoType"] = "application/vnd.apple.mpegurl"
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio != nil {
data["videoWidth"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Width
data["videoHeight"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Height
}
}
}
}
return c.Render(http.StatusOK, "post.html", data)
@@ -696,44 +670,6 @@ func (srv *Server) WebStarterPack(c echo.Context) error {
return c.Render(http.StatusOK, "starterpack.html", data)
}
// chatInviteCodeRe is a permissive sanity check on the join code so we don't
// proxy obviously-bad input to the chat appview. Codes are opaque per the
// lexicon, but in practice URL-safe and short.
var chatInviteCodeRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
func (srv *Server) WebChatInvite(c echo.Context) error {
req := c.Request()
ctx := req.Context()
data := srv.NewTemplateContext()
data["noindex"] = true
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
code := c.Param("code")
if !chatInviteCodeRe.MatchString(code) {
return c.Render(http.StatusOK, "chatinvite.html", data)
}
if srv.chatXrpcc == nil {
return c.Render(http.StatusOK, "chatinvite.html", data)
}
out, err := chat.GroupGetJoinLinkPreviews(ctx, srv.chatXrpcc, []string{code})
if err != nil {
log.Errorf("failed to fetch chat invite preview for code=%s: %v", code, err)
return c.Render(http.StatusOK, "chatinvite.html", data)
}
if len(out.JoinLinkPreviews) == 0 {
return c.Render(http.StatusOK, "chatinvite.html", data)
}
preview := out.JoinLinkPreviews[0]
data["title"] = preview.Name
if srv.cfg.ogcardHost != "" {
// bskyogcard registers this route as /chat-invite/:code, not /chat/:code.
data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code)
}
return c.Render(http.StatusOK, "chatinvite.html", data)
}
func (srv *Server) WebProfile(c echo.Context) error {
ctx := c.Request().Context()
data := srv.NewTemplateContext()
@@ -751,56 +687,20 @@ func (srv *Server) WebProfile(c echo.Context) error {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
return c.Render(http.StatusOK, "profile.html", data)
}
unauthedViewingOkay := !profileRequiresAuth(pv)
unauthedViewingOkay := true
for _, label := range pv.Labels {
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
unauthedViewingOkay = false
}
}
req := c.Request()
data["profileView"] = pv
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
data["requestHost"] = req.Host
// Prefer the handle-form URL so JSON-LD `url` and
// <link rel="canonical"> match. Template falls back to requestURI
// when the handle is unusable.
if url := bskyProfileURL(pv.Handle); url != "" {
data["canonicalURL"] = url
}
// Fetch recent posts for ProfilePage.hasPart. Skipped for auth-required
// profiles (posts aren't publicly indexable anyway). Failures degrade
// gracefully — the profile still renders without hasPart.
//
// NOTE: extra XRPC call on every public profile render; consider
// caching per-profile if upstream load becomes a concern.
var recentPosts []*appbsky.FeedDefs_PostView
if unauthedViewingOkay {
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, pv.Did, "", "posts_no_replies", false, authorFeedFetchLimit)
if err != nil {
log.Warnf("failed to fetch author feed for: %s\t%v", pv.Did, err)
} else {
for _, p := range af.Feed {
if p == nil || p.Post == nil {
continue
}
// Only the author's own posts (matches RSS handler).
if p.Post.Author == nil || p.Post.Author.Did != pv.Did {
continue
}
recentPosts = append(recentPosts, p.Post)
if len(recentPosts) >= maxRecentPosts {
break
}
}
}
} else {
if !unauthedViewingOkay {
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
}
if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels, hideReplyLabels); err == nil {
data["profileJSONLD"] = jsonld
} else {
log.Warnf("failed to build profile JSON-LD for %s: %v", pv.Did, err)
}
return c.Render(http.StatusOK, "profile.html", data)
@@ -830,7 +730,12 @@ func (srv *Server) WebFeed(c echo.Context) error {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
return c.Render(http.StatusOK, "feed.html", data)
}
unauthedViewingOkay := !profileRequiresAuth(pv)
unauthedViewingOkay := true
for _, label := range pv.Labels {
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
unauthedViewingOkay = false
}
}
if !unauthedViewingOkay {
return c.Render(http.StatusOK, "feed.html", data)
+1 -1
View File
@@ -95,7 +95,7 @@ func (srv *Server) parseBlueskyURL(ctx context.Context, raw string) (*syntax.ATU
}
var did syntax.DID
if atid.IsHandle() {
ident, err := srv.dir.Lookup(ctx, atid)
ident, err := srv.dir.Lookup(ctx, *atid)
if err != nil {
return nil, err
}
+10 -7
View File
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
go 1.26
require (
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a
github.com/flosch/pongo2/v6 v6.0.0
github.com/ipfs/go-log v1.0.5
github.com/joho/godotenv v1.5.1
@@ -18,10 +18,11 @@ require github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indir
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -31,6 +32,7 @@ require (
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/go-block-format v0.2.0 // indirect
@@ -80,6 +82,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
@@ -90,11 +93,11 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/protobuf v1.36.6 // indirect
+20 -14
View File
@@ -2,8 +2,12 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a h1:S12KN45uIkRglMHC8PqD/Vsz0+u3KbIaBF/6rit8/Pg=
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@@ -15,8 +19,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
@@ -46,6 +48,8 @@ github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg=
github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
@@ -207,6 +211,8 @@ github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSD
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic=
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -247,8 +253,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -266,16 +272,16 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -291,8 +297,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -304,8 +310,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-1
View File
@@ -139,7 +139,6 @@
<meta name="theme-color">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
{% if noindex %}<meta name="robots" content="noindex{% if nofollow %}, nofollow{% endif %}">{% endif %}
<meta property="og:site_name" content="Bluesky Social">
<meta property="og:logo" content="{{ favicon }}">
<meta name="twitter:site" content="@bluesky" />
-25
View File
@@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block html_head_extra -%}
{%- if requestURI %}
<meta property="og:url" content="{{ requestURI }}">
{% endif -%}
{%- if imgThumbUrl %}
<meta property="og:image" content="{{ imgThumbUrl }}">
<meta property="twitter:image" content="{{ imgThumbUrl }}">
{%- else -%}
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
{% endif -%}
<meta name="twitter:card" content="summary_large_image" />
{%- if title %}
<meta property="og:title" content="{{ title }}" />
<meta name="twitter:title" content="{{ title }}" />
{%- else -%}
<meta property="og:title" content="Bluesky" />
<meta name="twitter:title" content="Bluesky" />
{% endif -%}
<meta name="description" content="Join this group chat on Bluesky" />
<meta name="og:description" content="Join this group chat on Bluesky" />
<meta name="twitter:description" content="Join this group chat on Bluesky" />
{%- endblock %}
+45 -18
View File
@@ -15,13 +15,8 @@
<meta property="og:type" content="article">
<meta property="profile:username" content="{{ postView.Author.Handle }}">
{%- if requestURI %}
{% if canonicalURL %}
<meta property="og:url" content="{{ canonicalURL }}">
<link rel="canonical" href="{{ canonicalURL }}" />
{% else %}
<meta property="og:url" content="{{ requestURI }}">
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
{% endif %}
{% endif -%}
{%- if postView.Author.DisplayName %}
<meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
@@ -39,11 +34,6 @@
<meta property="twitter:image" content="{{ imgThumbUrl }}">
{% endfor %}
<meta name="twitter:card" content="summary_large_image">
{% else %}
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta name="twitter:card" content="summary">
{% endif %}
{%- if videoUrl %}
<meta property="og:video" content="{{ videoUrl }}">
<meta property="og:video:type" content="{{ videoType }}">
@@ -52,6 +42,11 @@
<meta property="og:video:height" content="{{ videoHeight }}">
{% endif -%}
{% endif -%}
{% else %}
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta name="twitter:card" content="summary">
{% endif %}
<meta name="twitter:label1" content="Posted At">
<meta name="twitter:value1" content="{{ postView.IndexedAt }}">
{%- if postView.LikeCount %}
@@ -69,20 +64,52 @@
<meta property="article:published_time" content="{{ postView.IndexedAt }}">
<link rel="alternate" type="application/json+oembed" href="https://embed.bsky.app/oembed?format=json&url={{ postView.Uri | urlencode }}" />
<link rel="alternate" href="{{ postView.Uri }}" />
{%- if postJSONLD %}
<script type="application/ld+json">{{ postJSONLD|safe }}</script>
{% endif -%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
"author": {
"@type": "Person",
{%- if postView.Author.DisplayName %}
"name": "{{ postView.Author.DisplayName }}",
"alternateName": "@{{ postView.Author.Handle }}",
{% else %}
"name": "@{{ postView.Author.Handle }}",
{% endif -%}
"url": "https://bsky.app/profile/{{ postView.Author.Handle }}"
},
{%- if postText %}
"text": "{{ postText }}",
{% endif %}
{%- if imageThumbUrls %}
"image": "{{ imageThumbUrls[0] }}",
{% endif %}
"datePublished": "{{ postView.IndexedAt }}",
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/LikeAction",
"userInteractionCount": {{ postView.LikeCount }}
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/CommentAction",
"userInteractionCount": {{ postView.ReplyCount }}
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/ShareAction",
"userInteractionCount": {{ postView.RepostCount + postView.QuoteCount }}
}
]
}
</script>
{%- elif requiresAuth and profileHandle -%}
<meta property="og:type" content="article">
<meta property="profile:username" content="{{ profileHandle }}">
{%- if requestURI %}
{% if canonicalURL %}
<meta property="og:url" content="{{ canonicalURL }}">
<link rel="canonical" href="{{ canonicalURL }}" />
{% else %}
<meta property="og:url" content="{{ requestURI }}">
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
{% endif %}
{% endif -%}
{%- if profileDisplayName %}
<meta property="og:title" content="{{ profileDisplayName }} (@{{ profileHandle }})">
+38 -8
View File
@@ -12,13 +12,8 @@
<meta property="og:site_name" content="Bluesky Social">
<meta property="og:type" content="profile">
{%- if requestURI %}
{% if canonicalURL %}
<meta property="og:url" content="{{ canonicalURL }}">
<link rel="canonical" href="{{ canonicalURL }}" />
{% else %}
<meta property="og:url" content="{{ requestURI }}">
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
{% endif %}
{% endif -%}
{%- if profileView -%}
@@ -57,9 +52,44 @@
<meta property="twitter:description" content="This profile requires authentication to view.">
{% endif %}
{%- if profileJSONLD %}
<script type="application/ld+json">{{ profileJSONLD|safe }}</script>
{% endif -%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProfilePage",
"dateCreated": "{{ profileView.CreatedAt }}",
"mainEntity": {
"@type": "Person",
{%- if profileView.DisplayName %}
"name": "{{ profileView.DisplayName }}",
"alternateName": "@{{ profileView.Handle }}",
{% else %}
"name": "@{{ profileView.Handle }}",
{% endif -%}
"identifier": "{{ profileView.Did }}",
"description": "{{ profileView.Description }}",
"image": "{{ profileView.Avatar }}",
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/FollowAction",
"userInteractionCount": {{ profileView.FollowersCount }}
}
],
"agentInteractionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/FollowAction",
"userInteractionCount": {{ profileView.FollowsCount }}
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/WriteAction",
"userInteractionCount": {{ profileView.PostsCount }}
}
]
}
}
</script>
{% endif -%}
{%- endblock %}
+1 -2
View File
@@ -1,7 +1,7 @@
import {createServer as createHTTPServer} from 'node:http'
import {parse} from 'node:url'
import {createServer, type TestPDS} from './test-pds.ts'
import {createServer, type TestPDS} from './test-pds'
let server: TestPDS
// eslint-disable-next-line @typescript-eslint/no-misused-promises
@@ -97,7 +97,6 @@ createHTTPServer(async (req, res) => {
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
console.log('Generating mock posts')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let posts: Record<string, any[]> = {
alice: [],
bob: [],
+4 -8
View File
@@ -1,16 +1,12 @@
{
"name": "dev-env",
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts"
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
},
"dependencies": {
"@atproto/api": "^0.20.0",
"@atproto/dev-env": "^0.5.0",
"typescript": "^6.0.3"
},
"devDependencies": {
"@types/node": "^25.8.0"
"@atproto/dev-env": "^0.3.215",
"ts-node": "^10.9.2",
"typescript": "^6.0.2"
}
}
-4447
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
allowBuilds:
'better-sqlite3': true
'core-js': true
'protobufjs': true
'sharp': true
'unrs-resolver': true
minimumReleaseAgeExclude:
- '@atproto/*'
- '@atproto-labs/*'
+22 -28
View File
@@ -1,9 +1,8 @@
import fs from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import {AtUri, BskyAgent} from '@atproto/api'
import {type TestBsky, TestNetwork} from '@atproto/dev-env'
import fs from 'fs'
import net from 'net'
import path from 'path'
export interface TestUser {
email: string
@@ -22,11 +21,9 @@ export interface TestPDS {
class StringIdGenerator {
_nextId = [0]
_chars: string
constructor(chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') {
this._chars = chars
}
constructor(
public _chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
) {}
next() {
const r = []
@@ -86,13 +83,13 @@ export async function createServer(
// DISABLED - looks like dev-env added this and now it conflicts
// add the test mod authority
// const agent = new BskyAgent({service: pdsUrl})
// const res = await agent.com.atproto.server.createAccount({
// const res = await agent.api.com.atproto.server.createAccount({
// email: 'mod-authority@test.com',
// handle: 'mod-authority.test',
// password: 'hunter2',
// })
// agent.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
// await agent.app.bsky.actor.profile.create(
// agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
// await agent.api.app.bsky.actor.profile.create(
// {repo: res.data.did},
// {
// displayName: 'Dev-env Moderation',
@@ -100,7 +97,7 @@ export async function createServer(
// },
// )
// await agent.app.bsky.labeler.service.create(
// await agent.api.app.bsky.labeler.service.create(
// {repo: res.data.did, rkey: 'self'},
// {
// policies: {
@@ -112,7 +109,7 @@ export async function createServer(
// )
const pic = fs.readFileSync(
path.join(import.meta.dirname, '..', 'assets', 'default-avatar.png'),
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
)
return {
@@ -128,14 +125,12 @@ export async function createServer(
class Mocker {
agent: BskyAgent
users: Record<string, TestUser> = {}
testNet: TestNetwork
service: string
pic: Uint8Array
constructor(testNet: TestNetwork, service: string, pic: Uint8Array) {
this.testNet = testNet
this.service = service
this.pic = pic
constructor(
public testNet: TestNetwork,
public service: string,
public pic: Uint8Array,
) {
this.agent = new BskyAgent({service})
}
@@ -166,7 +161,7 @@ class Mocker {
async createUser(name: string) {
const agent = new BskyAgent({service: this.service})
const inviteRes = await agent.com.atproto.server.createInviteCode(
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
{useCount: 1},
{
headers: this.pds.adminAuthHeaders(),
@@ -314,21 +309,20 @@ class Mocker {
'app.bsky.feed.generator',
rkey,
)
// @ts-expect-error
const fg1 = await this.testNet.createFeedGen({
[fgUri.toString()]: () => {
[fgUri.toString()]: async () => {
return {
encoding: 'application/json' as const,
encoding: 'application/json',
body: {
feed: posts.slice(0, 30).map(uri => ({post: uri})),
},
}
},
})
const avatarRes = await agent.com.atproto.repo.uploadBlob(this.pic, {
const avatarRes = await agent.api.com.atproto.repo.uploadBlob(this.pic, {
encoding: 'image/png',
})
return await agent.app.bsky.feed.generator.create(
return await agent.api.app.bsky.feed.generator.create(
{repo: this.users[user].did, rkey},
{
did: fg1.did,
@@ -342,7 +336,7 @@ class Mocker {
async createInvite(forAccount: string) {
const agent = new BskyAgent({service: this.service})
await agent.com.atproto.server.createInviteCode(
await agent.api.com.atproto.server.createInviteCode(
{useCount: 1, forAccount},
{
headers: this.pds.adminAuthHeaders(),
+37 -7
View File
@@ -1,17 +1,47 @@
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"lib": ["esnext"],
"module": "commonjs",
"types": ["react-native", "jest"],
"lib": [
"es2019",
"es2020.bigint",
"es2020.date",
"es2020.number",
"es2020.promise",
"es2020.string",
"es2020.symbol.wellknown",
"es2021.promise",
"es2021.string",
"es2021.weakref",
"es2022.array",
"es2022.object",
"es2022.string"
],
"allowJs": true,
"jsx": "react-native",
"noEmit": true,
"isolatedModules": true,
"strict": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": false,
"esModuleInterop": true,
"skipLibCheck": true,
"erasableSyntaxOnly": true,
"allowImportingTsExtensions": true
"paths": {
"#/*": ["./src/*"],
"lib/*": ["./src/lib/*"],
"platform/*": ["./src/platform/*"],
"state/*": ["./src/state/*"],
"view/*": ["./src/view/*"],
"crypto": ["./src/platform/crypto.ts"]
}
},
"exclude": ["node_modules"]
"exclude": [
"node_modules",
"babel.config.js",
"metro.config.js",
"jest.config.js"
]
}
+4424
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"cli": {
"version": ">=18",
"version": "^18.13.0",
"promptToConfigurePushNotifications": false,
"appVersionSource": "remote"
},
+18 -1854
View File
File diff suppressed because it is too large Load Diff
+22 -20
View File
@@ -134,10 +134,11 @@ export default defineConfig(
'react-native/no-inline-styles': 'off',
...reactNativeA11y.configs.all.rules,
'react-compiler/react-compiler': 'warn',
'react-hooks/set-state-in-effect': 'error',
'react-hooks/purity': 'error',
'react-hooks/refs': 'error',
'react-hooks/immutability': 'error',
// TODO: Fix these and set to error
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/refs': 'warn',
'react-hooks/immutability': 'warn',
/**
* Import sorting
@@ -234,8 +235,9 @@ export default defineConfig(
},
],
/**
* Maintain previous behavior via eslint-suppressions.json - these are
* stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
* Maintain previous behavior - these are stricter in typescript-eslint
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
* nit-picky
*/
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/ban-ts-comment': 'off',
@@ -245,18 +247,18 @@ export default defineConfig(
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-redundant-type-constituents': 'error',
'@typescript-eslint/no-duplicate-type-constituents': 'error',
'@typescript-eslint/no-base-to-string': 'error',
'@typescript-eslint/prefer-promise-reject-errors': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-misused-promises': 'warn',
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
'@typescript-eslint/no-redundant-type-constituents': 'warn',
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
'@typescript-eslint/await-thenable': 'warn',
'no-restricted-imports': [
'error',
@@ -294,10 +296,10 @@ export default defineConfig(
},
/**
* bskyogcard, dev-env - server-side, Node.js imports are fine
* bskyogcard - server-side, Node.js imports are fine
*/
{
files: ['bskyogcard/**/*.{js,jsx,ts,tsx}', 'dev-env/**/*.{js,jsx,ts,tsx}'],
files: ['bskyogcard/**/*.{js,jsx,ts,tsx}'],
rules: {
'import-x/no-nodejs-modules': 'off',
},
+4
View File
@@ -17,6 +17,10 @@ jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => {
}
})
jest.mock('@fortawesome/react-native-fontawesome', () => ({
FontAwesomeIcon: '',
}))
jest.mock('react-native-safe-area-context', () => {
const inset = {top: 0, right: 0, bottom: 0, left: 0}
return {
+24 -37
View File
@@ -1,6 +1,6 @@
import Intents
import UIKit
import UserNotifications
import UIKit
import Intents
let APP_GROUP = "group.app.bsky"
typealias ContentHandler = (UNNotificationContent) -> Void
@@ -30,14 +30,11 @@ class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ContentHandler?
private var bestAttempt: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
guard let bestAttempt = NSEUtil.createCopy(request.content),
let reason = request.content.userInfo["reason"] as? String
let reason = request.content.userInfo["reason"] as? String
else {
contentHandler(request.content)
return
@@ -61,8 +58,8 @@ class NotificationService: UNNotificationServiceExtension {
)
contentHandler(finalContent)
} else if reason == "chat-added-to-group"
|| reason == "chat-removed-from-group"
|| reason == "chat-join-request-rejected" {
|| reason == "chat-removed-from-group"
|| reason == "chat-join-request-rejected" {
mutateWithChatMessage(bestAttempt)
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
mutateWithBadge(bestAttempt)
@@ -75,8 +72,7 @@ class NotificationService: UNNotificationServiceExtension {
override func serviceExtensionTimeWillExpire() {
guard let contentHandler = self.contentHandler,
let bestAttempt = self.bestAttempt
else {
let bestAttempt = self.bestAttempt else {
return
}
contentHandler(bestAttempt)
@@ -90,7 +86,7 @@ class NotificationService: UNNotificationServiceExtension {
) -> UNNotificationContent {
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
let convoId = userInfo["convoId"] as? String
var avatarImage: INImage?
var avatarImage: INImage? = nil
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
avatarImage = downloadAvatarImage(from: avatarUrlString)
}
@@ -106,10 +102,10 @@ class NotificationService: UNNotificationServiceExtension {
customIdentifier: nil
)
var speakableGroupName: INSpeakableString?
var speakableGroupName: INSpeakableString? = nil
if userInfo["convoKind"] as? String == "group",
let groupName = userInfo["groupName"] as? String,
!groupName.isEmpty {
let groupName = userInfo["groupName"] as? String,
!groupName.isEmpty {
speakableGroupName = INSpeakableString(spokenPhrase: groupName)
}
@@ -130,8 +126,8 @@ class NotificationService: UNNotificationServiceExtension {
// `recipientCount` on the donation metadata is Apple's documented escape
// hatch for this case, and overrides the (zero-length) recipients array.
if userInfo["convoKind"] as? String == "group",
let groupMemberCount = userInfo["groupMemberCount"] as? Int,
groupMemberCount > 0 {
let groupMemberCount = userInfo["groupMemberCount"] as? Int,
groupMemberCount > 0 {
let metadata = INSendMessageIntentDonationMetadata()
metadata.recipientCount = groupMemberCount
intent.donationMetadata = metadata
@@ -140,17 +136,10 @@ class NotificationService: UNNotificationServiceExtension {
// For group convos, attach the composite group avatar (rendered by the
// ogcard service) to the `speakableGroupName` parameter so iOS shows it
// alongside the sender on the Communication Notification.
// Disabled: the composite avatar renders poorly at notification size.
// if userInfo["convoKind"] as? String == "group",
// let convoAvatarUrlString = userInfo["convoAvatarUrl"] as? String,
// let groupImage = downloadAvatarImage(from: convoAvatarUrlString) {
// intent.setImage(groupImage, forParameterNamed: \.speakableGroupName)
// }
// Set the group image to the sender avatar instead
if userInfo["convoKind"] as? String == "group",
let avatarImage {
intent.setImage(avatarImage, forParameterNamed: \.speakableGroupName)
let convoAvatarUrlString = userInfo["convoAvatarUrl"] as? String,
let groupImage = downloadAvatarImage(from: convoAvatarUrlString) {
intent.setImage(groupImage, forParameterNamed: \.speakableGroupName)
}
let interaction = INInteraction(intent: intent, response: nil)
@@ -175,13 +164,13 @@ class NotificationService: UNNotificationServiceExtension {
var request = URLRequest(url: url)
request.timeoutInterval = 5
var imageData: Data?
var imageData: Data? = nil
let semaphore = DispatchSemaphore(value: 0)
let task = URLSession.shared.dataTask(with: request) { data, response, _ in
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
imageData = data
}
semaphore.signal()
@@ -220,9 +209,8 @@ class NotificationService: UNNotificationServiceExtension {
userInfo: [AnyHashable: Any]
) {
guard userInfo["convoKind"] as? String == "group",
let groupName = userInfo["groupName"] as? String,
!groupName.isEmpty
else {
let groupName = userInfo["groupName"] as? String,
!groupName.isEmpty else {
return
}
content.subtitle = groupName
@@ -238,9 +226,8 @@ class NotificationService: UNNotificationServiceExtension {
userInfo: [AnyHashable: Any]
) {
guard let messageKind = userInfo["messageKind"] as? String,
messageKind != "message",
!content.title.isEmpty
else {
messageKind != "message",
!content.title.isEmpty else {
return
}
content.body = content.title
@@ -41,14 +41,11 @@ class BottomSheetView(
private val screenHeight: Float =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels
.toFloat()
context.resources.displayMetrics.heightPixels.toFloat()
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds
.height()
.toFloat()
wm.currentWindowMetrics.bounds.height().toFloat()
} else {
// API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them)
@@ -169,7 +166,6 @@ class BottomSheetView(
when {
// Full height sheets
contentHeight >= screenHeight -> 0.99f
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
}
@@ -275,9 +271,8 @@ class BottomSheetView(
}
// Apply deferred layout update after gesture completes
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
newState != BottomSheetBehavior.STATE_SETTLING &&
pendingLayoutUpdate
) {
newState != BottomSheetBehavior.STATE_SETTLING &&
pendingLayoutUpdate) {
pendingLayoutUpdate = false
updateLayout()
}
@@ -297,6 +292,7 @@ class BottomSheetView(
if (!fullHeight) {
this.startObservingContentHeight()
}
}
fun updateLayout() {
@@ -369,18 +365,17 @@ class BottomSheetView(
val innerViewGroup = this.innerView as? ViewGroup ?: return
val listener =
OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
val newHeight = bottom - top
val oldHeight = oldBottom - oldTop
if (newHeight != oldHeight) {
val contentHeight = getContentHeight()
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
lastObservedContentHeight = contentHeight
updateLayout()
}
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
val newHeight = bottom - top
val oldHeight = oldBottom - oldTop
if (newHeight != oldHeight) {
val contentHeight = getContentHeight()
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
lastObservedContentHeight = contentHeight
updateLayout()
}
}
}
val children = mutableListOf<View>()
for (i in 0 until innerViewGroup.childCount) {
@@ -67,18 +67,7 @@ export class GifView extends PureComponent<GifViewProps> {
}
async playAsync(): Promise<void> {
try {
await this.videoPlayerRef.current?.play()
} catch (err) {
// `play()` rejects with a NotAllowedError when the browser blocks
// playback (e.g. Safari low-power mode or autoplay policy). This is
// expected and benign - the GIF simply stays paused - so swallow it
// rather than letting it surface as an unhandled rejection.
if (err instanceof DOMException && err.name === 'NotAllowedError') {
return
}
throw err
}
this.videoPlayerRef.current?.play()
}
async pauseAsync(): Promise<void> {
@@ -1,12 +1,12 @@
import React from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import {StyleProp, ViewStyle} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {type VisibilityViewProps} from './types'
import {VisibilityViewProps} from './types'
const NativeView: React.ComponentType<{
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
children: React.ReactNode
enabled: boolean
enabled: Boolean
style: StyleProp<ViewStyle>
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
@@ -1,12 +1,11 @@
import ExpoModulesCore
import WebKit
import MCEmojiPicker
class EmojiPickerView: ExpoView, MCEmojiPickerDelegate {
let onEmojiSelected = EventDispatcher()
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
override func layoutSubviews() {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
self.addGestureRecognizer(tapGesture)
}
@@ -20,19 +19,9 @@ class EmojiPickerView: ExpoView, MCEmojiPickerDelegate {
let reactRootVC = reactViewController()
emojiPicker.sourceView = self
emojiPicker.delegate = self
emojiPicker.arrowDirection = preferredArrowDirection()
reactRootVC?.present(emojiPicker, animated: true)
}
/// If the trigger sits in the bottom third of the screen there isn't enough
/// room below it for a usable popover, so anchor the picker above instead.
private func preferredArrowDirection() -> MCPickerArrowDirection {
guard let window = self.window else { return .up }
let frameInWindow = self.convert(self.bounds, to: window)
let threshold = window.bounds.height * (2.0 / 3.0)
return frameInWindow.midY >= threshold ? .down : .up
}
func didGetEmoji(emoji: String) {
onEmojiSelected([
"emoji": emoji
@@ -6,7 +6,6 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.core.net.toUri
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
@@ -14,8 +13,6 @@ import java.io.File
import java.io.FileOutputStream
import java.net.URLEncoder
private const val TAG = "ExpoReceiveAndroidIntents"
enum class AttachmentType {
IMAGE,
VIDEO,
@@ -122,15 +119,17 @@ class ExpoReceiveAndroidIntentsModule : Module() {
uris: List<Uri>,
text: String?,
) {
// Some URIs we receive may be unreadable (revoked permission, deleted file,
// a provider that rejects the read). Skip those rather than crashing the
// whole app, since this runs synchronously on the module init path.
val allParams =
uris
.mapNotNull { uri -> getImageInfo(uri) }
.joinToString(",") { info -> buildUriData(info) }
var allParams = ""
if (allParams.isEmpty()) return
uris.forEachIndexed { index, uri ->
val info = getImageInfo(uri)
val params = buildUriData(info)
allParams = "${allParams}$params"
if (index < uris.count() - 1) {
allParams = "$allParams,"
}
}
val encodedUris = URLEncoder.encode(allParams, "UTF-8")
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
@@ -159,30 +158,12 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
val file = createFile(extension)
// The URI may be unreadable (revoked permission, deleted file, or a
// provider that rejects the read). Bail rather than crashing the whole
// app, since this runs synchronously on the module init path.
try {
FileOutputStream(file).use { out ->
val input =
appContext.currentActivity?.contentResolver?.openInputStream(uri)
?: run {
file.delete()
return
}
input.use { it.copyTo(out) }
}
} catch (e: Exception) {
Log.w(TAG, "Failed to copy shared video to cache", e)
file.delete()
return
val out = FileOutputStream(file)
appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use {
it.copyTo(out)
}
val info =
getVideoInfo(uri) ?: run {
file.delete()
return
}
val info = getVideoInfo(uri) ?: return
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
@@ -195,29 +176,15 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
}
private fun getImageInfo(uri: Uri): Map<String, Any>? {
val bitmap =
try {
MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
} catch (e: Exception) {
// The URI may be unreadable (revoked permission, deleted file, or a
// provider that rejects the read). Skip this image rather than crash.
Log.w(TAG, "Failed to read shared image", e)
return null
} ?: return null
private fun getImageInfo(uri: Uri): Map<String, Any> {
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
// We have to save this so that we can access it later when uploading the image.
// createTempFile will automatically place a unique string between "img" and "temp.jpeg"
val file = createFile("jpeg")
try {
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to write shared image to cache", e)
file.delete()
return null
}
val out = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
out.close()
return mapOf(
"width" to bitmap.width,
@@ -228,19 +195,10 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun getVideoInfo(uri: Uri): Map<String, Any>? {
val retriever = MediaMetadataRetriever()
val width: Int?
val height: Int?
try {
retriever.setDataSource(appContext.currentActivity, uri)
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} catch (e: Exception) {
// The URI may be unreadable or not a valid media source. Skip rather than crash.
Log.w(TAG, "Failed to read shared video metadata", e)
return null
} finally {
retriever.release()
}
retriever.setDataSource(appContext.currentActivity, uri)
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
if (width == null || height == null) {
return null
+27 -28
View File
@@ -1,22 +1,16 @@
{
"name": "bsky.app",
"version": "1.124.0",
"version": "1.122.0",
"private": true,
"engines": {
"node": ">=24.15.0"
},
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.5.2",
"onFail": "warn"
},
"node": ">=24.15.0",
"runtime": {
"name": "node",
"version": "^24.15.0",
"onFail": "download"
}
},
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
"expo": {
"autolinking": {
"android": {
@@ -59,11 +53,11 @@
"test-watch": "NODE_ENV=test jest --watchAll",
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --quiet src modules",
"lint": "eslint --cache --quiet src",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsgo --project ./tsconfig.check.json",
"e2e:mock-server": "cd dev-env && pnpm start",
"e2e:mock-server": "cd dev-env && yarn start",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
@@ -89,25 +83,22 @@
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
"icons:optimize": "svgo -f ./assets/icons",
"prettier": "prettier --check ."
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "0.20.11",
"@atproto/api": "0.20.4",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14",
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
"@bsky.app/alf": "^0.1.9",
"@bsky.app/expo-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.2.4",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.8",
"@bsky.app/tapper": "^0.5.7",
"@bsky.app/video": "0.3.6",
"@bsky.app/sift": "^0.3.4",
"@bsky.app/tapper": "^0.5.3",
"@bsky.app/video": "0.3.4",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
@@ -115,15 +106,21 @@
"@expo/webpack-config": "^19.0.1",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
"@formatjs/intl-displaynames": "^7.3.9",
"@formatjs/intl-locale": "^5.3.8",
"@formatjs/intl-numberformat": "^9.3.10",
"@formatjs/intl-pluralrules": "^6.3.9",
"@formatjs/intl-displaynames": "^6.8.13",
"@formatjs/intl-locale": "^4.2.13",
"@formatjs/intl-numberformat": "^8.15.6",
"@formatjs/intl-pluralrules": "^5.4.6",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@growthbook/growthbook": "^1.6.5",
"@growthbook/growthbook-react": "^1.6.5",
"@ipld/dag-cbor": "^9.2.7",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/native": "^7.1.33",
@@ -151,7 +148,7 @@
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
"date-fns": "^4.4.0",
"date-fns": "^2.30.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
@@ -221,9 +218,9 @@
"react-native-date-picker": "^5.0.13",
"react-native-device-attest": "^0.1.6",
"react-native-drawer-layout": "^4.2.3",
"react-native-edge-to-edge": "^1.8.1",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.21.8",
"react-native-keyboard-controller": "^1.21.7",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
@@ -275,6 +272,8 @@
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"babel-preset-expo": "~54.0.10",
"eas-cli": "^18.13.0",
"eas-cli-local-build-plugin": "^18.12.3",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-bsky-internal": "link:eslint",
-36
View File
@@ -13,39 +13,3 @@ index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8
// Check for Content-Type
val skipContentTypes = listOf(
"text/event-stream", // Server Sent Events
diff --git a/ios/Core/ExpoBridgeModule.mm b/ios/Core/ExpoBridgeModule.mm
index 2ed1c00f47406e109750cc27ace7e0d88e42c00e..99d0d140eddf95a8db7beb57c61dfcb12c1424b4 100644
--- a/ios/Core/ExpoBridgeModule.mm
+++ b/ios/Core/ExpoBridgeModule.mm
@@ -7,6 +7,9 @@
// The runtime executor is included as of React Native 0.74 in bridgeless mode.
#if __has_include(<ReactCommon/RCTRuntimeExecutor.h>)
#import <ReactCommon/RCTRuntimeExecutor.h>
+#else // React Native <0.74
+// dispatchBlock:queue: is declared in RCTBridge+Private.h, not the public header.
+#import <React/RCTBridge+Private.h>
#endif // React Native >=0.74
@implementation ExpoBridgeModule
@@ -46,7 +49,20 @@ - (void)setBridge:(RCTBridge *)bridge
_appContext.reactBridge = bridge;
#if !__has_include(<ReactCommon/RCTRuntimeExecutor.h>)
- _appContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:bridge];
+ // Hop the runtime install (and the prepareRuntime() chain it triggers via
+ // _runtime.didSet) onto RCTJSThread. The original line ran synchronously
+ // on whatever thread called setBridge: - typically the main thread - and
+ // raced JSIExecutor::initializeRuntime() on the JS thread, corrupting
+ // Hermes' Hades GC (HadesGC::writeBarrierSlow EXC_BAD_ACCESS).
+ __weak EXAppContext *weakAppContext = _appContext;
+ __weak RCTBridge *weakBridge = bridge;
+ [bridge dispatchBlock:^{
+ EXAppContext *strongAppContext = weakAppContext;
+ RCTBridge *strongBridge = weakBridge;
+ if (strongAppContext != nil && strongBridge != nil && strongAppContext._runtime == nil) {
+ strongAppContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:strongBridge];
+ }
+ } queue:RCTJSThread];
#endif // React Native <0.74
}
+1 -24
View File
@@ -1,26 +1,3 @@
## expo-modules-core Patch
This patch contains two unrelated fixes:
### Android: bitdrift interceptor
Fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools.
### iOS: Hermes startup race in `ExpoBridgeModule.setBridge:`
On the legacy bridge (old architecture, where `RCTRuntimeExecutor.h` is
absent), `setBridge:` installed the Expo runtime synchronously on whatever
thread called it - typically the main thread, since RN's lazy module-load
path ignores `+requiresMainQueueSetup`. The `_runtime.didSet` then ran
`prepareRuntime()` (JSI mutations) on the main thread while the JS thread was
concurrently inside `JSIExecutor::initializeRuntime()`. Two threads mutating
the same Hermes runtime corrupted Hades GC, producing intermittent
`EXC_BAD_ACCESS` launch crashes (e.g. `HadesGC::writeBarrierSlow`,
`prepareRuntime` / `bindNativePerformanceNow`).
The fix hops the runtime install onto `RCTJSThread` so all JSI mutation is
serialized on the JS thread. This backports the upstream fix discussed in
expo/expo#45374; the racy `ExpoBridgeModule` is removed entirely in SDK 55+
(expo/expo#44351), so this patch can be dropped on that upgrade.
Refs: expo/expo#43003, expo/expo#45374, expo/expo#44351
This patch fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools
@@ -1,5 +1,5 @@
diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1a01093e7909973cef5268f999158df389e77634 100644
index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da84061fd7f77c 100644
--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
+++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
@@ -1,8 +1,6 @@
@@ -19,7 +19,7 @@ index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1a01093e7909973cef5268f999158df3
inverted,
keyboardLiftBehavior,
freeze,
@@ -62,20 +59,23 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
(target: number) => {
"worklet";
@@ -37,15 +37,6 @@ index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1a01093e7909973cef5268f999158df3
+ // otherwise the native ScrollView clamps contentOffset to the old
+ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android).
+ requestAnimationFrame(() => {
+ // The ScrollView can detach between scheduling this frame and now (e.g.
+ // navigating away from the screen, or the list re-creating its scroll
+ // component). Once detached, scrollViewRef() resolves to null, and on
+ // Paper reanimated's scrollTo hands that null to the native
+ // _scrollToPaper HostFunction, which throws "Value is null, expected a
+ // number". Re-check the ref now that the frame has arrived.
+ if (scrollViewRef() == null) {
+ return;
+ }
scrollTo(scrollViewRef, 0, target, false);
- }
+ });
+12 -29
View File
@@ -1,39 +1,22 @@
diff --git a/lib/module/toast.js b/lib/module/toast.js
index be089f3ff23017a6844e2010cedc547729e198aa..c2dd0fa2df93f929c33bf2bcc3e6f921941fa006 100644
--- a/lib/module/toast.js
+++ b/lib/module/toast.js
@@ -1,8 +1,8 @@
"use strict";
import * as React from 'react';
-import { ActivityIndicator, Pressable, Text, View } from 'react-native';
-import Animated, { useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
+import { ActivityIndicator, Pressable, Text, View, Platform } from 'react-native';
+import Animated, { useAnimatedStyle, useSharedValue, withRepeat, withTiming, FadeOut } from 'react-native-reanimated';
import { ANIMATION_DURATION, useToastLayoutAnimations } from "./animations.js";
import { toastDefaultValues } from "./constants.js";
import { useToastContext } from "./context.js";
@@ -258,7 +258,10 @@ export const Toast = /*#__PURE__*/React.forwardRef(({
diff --git a/lib/commonjs/toast.js b/lib/commonjs/toast.js
index 121816a452339c1088aeba87928ff63a0bdacca5..47e74bce47323201f0bb4e5ed9dc55b373c07b97 100644
--- a/lib/commonjs/toast.js
+++ b/lib/commonjs/toast.js
@@ -264,7 +264,7 @@ const Toast = exports.Toast = /*#__PURE__*/React.forwardRef(({
...toastSwipeHandlerProps,
children: /*#__PURE__*/_jsx(Animated.View, {
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
entering: entering,
- exiting: exiting,
+ exiting: Platform.select({
+ android: undefined,
+ default: exiting
+ }),
+ exiting: _reactNative.Platform.OS === 'android' ? undefined : exiting,
children: jsx
})
});
@@ -268,7 +271,10 @@ export const Toast = /*#__PURE__*/React.forwardRef(({
children: /*#__PURE__*/_jsx(Animated.View, {
@@ -274,7 +274,7 @@ const Toast = exports.Toast = /*#__PURE__*/React.forwardRef(({
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
style: [unstyled ? undefined : elevationStyle, defaultStyles.toast, toastStyleCtx, styles?.toast, style, wiggleAnimationStyle],
entering: entering,
- exiting: exiting,
+ exiting: Platform.select({
+ android: undefined,
+ default: exiting
+ }),
children: /*#__PURE__*/_jsxs(View, {
+ exiting: _reactNative.Platform.OS === 'android' ? undefined : exiting,
children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: [defaultStyles.toastContent, toastContentStyleCtx, styles?.toastContent],
children: [promiseOptions || variant === 'loading' ? 'loading' in icons ? icons.loading : /*#__PURE__*/_jsx(ActivityIndicator, {}) : icon ? /*#__PURE__*/_jsx(View, {
children: [promiseOptions || variant === 'loading' ? 'loading' in icons ? icons.loading : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ActivityIndicator, {}) : icon ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
+2782 -577
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,8 +1,6 @@
nodeLinker: 'hoisted'
autoInstallPeers: true # default: true
strictPeerDependencies: false # default: false
trustPolicy: 'no-downgrade' # default: off
trustPolicyIgnoreAfter: 10080 # 7 days, default: undefined
overrides:
'@react-native/babel-preset': '0.81.5'
'@react-native/normalize-colors': '0.81.5'
@@ -16,6 +14,7 @@ overrides:
allowBuilds:
'@sentry/cli': true
'core-js-pure': true
'dtrace-provider': true
'esbuild': true
'unrs-resolver': true
patchedDependencies:
@@ -32,7 +31,7 @@ patchedDependencies:
'react-native-compressor@1.13.0': patches/react-native-compressor@1.13.0.patch
'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch
'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch
'react-native-keyboard-controller@1.21.8': patches/react-native-keyboard-controller@1.21.8.patch
'react-native-keyboard-controller@1.21.7': patches/react-native-keyboard-controller@1.21.7.patch
'react-native-pager-view@6.8.0': patches/react-native-pager-view@6.8.0.patch
'react-native-reanimated@3.19.1': patches/react-native-reanimated@3.19.1.patch
'react-native-svg@15.12.1': patches/react-native-svg@15.12.1.patch
+1 -6
View File
@@ -20,13 +20,8 @@ DEPLOYMENT_URL="https://updates.bsky.app/v1/upload?runtime-version=$RUNTIME_VERS
tar czvf bundle.tar.gz ./*
echo "Deploying to $DEPLOYMENT_URL..."
echo " runtime-version: $RUNTIME_VERSION"
echo " bundle-version: $BUNDLE_VERSION"
echo " channel: $CHANNEL_NAME"
echo " ios-build-number: $BSKY_IOS_BUILD_NUMBER"
echo " android-build-number: $BSKY_ANDROID_VERSION_CODE"
curl --fail-with-body -o - --form "bundle=@./bundle.tar.gz" --user "bsky:$DENIS_API_KEY" --basic "$DEPLOYMENT_URL"
curl -o - --form "bundle=@./bundle.tar.gz" --user "bsky:$DENIS_API_KEY" --basic "$DEPLOYMENT_URL"
cd ..
-4
View File
@@ -1,8 +1,4 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
outputIos=$(eas build:version:get -p ios)
outputAndroid=$(eas build:version:get -p android)
BSKY_IOS_BUILD_NUMBER=${outputIos#*buildNumber - }
-4
View File
@@ -1,8 +1,4 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
outputIos=$(eas build:version:get -p ios)
outputAndroid=$(eas build:version:get -p android)
currentIosVersion=${outputIos#*buildNumber - }
+7 -6
View File
@@ -1,4 +1,5 @@
import '#/logger/sentry/setup'
import '#/view/icons'
import {Fragment, useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
@@ -47,18 +48,18 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LandingProvider} from '#/state/shell/landing'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Shell} from '#/view/shell'
import {atoms as a, ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -113,7 +114,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {t: l} = useLingui()
const hasCheckedLanding = useLandingEntry()
const hasCheckedReferrer = useStarterPackEntry()
// init
useEffect(() => {
@@ -146,7 +147,7 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<ContextMenuProvider>
<Splash isReady={isReady && hasCheckedLanding}>
<Splash isReady={isReady && hasCheckedReferrer}>
<VideoVolumeProvider>
<Fragment
// Resets the entire tree below when it changes:
@@ -246,12 +247,12 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<LandingProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</LandingProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
+7 -6
View File
@@ -1,4 +1,5 @@
import '#/logger/sentry/setup' // must be near top
import '#/view/icons'
import './style.css'
import {Fragment, useEffect, useState} from 'react'
@@ -40,17 +41,17 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LandingProvider} from '#/state/shell/landing'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -92,7 +93,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {t: l} = useLingui()
const hasCheckedLanding = useLandingEntry()
const hasCheckedReferrer = useStarterPackEntry()
// init
useEffect(() => {
@@ -125,7 +126,7 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<ContextMenuProvider>
<Splash isReady={isReady && hasCheckedLanding}>
<Splash isReady={isReady && hasCheckedReferrer}>
<VideoVolumeProvider>
<ActiveVideoProvider>
<Fragment
@@ -224,9 +225,9 @@ function App() {
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<LandingProvider>
<StarterPackProvider>
<InnerApp />
</LandingProvider>
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
+95 -26
View File
@@ -44,7 +44,6 @@ import {
type State,
} from '#/lib/routes/types'
import {bskyTitle} from '#/lib/strings/headings'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
@@ -82,7 +81,6 @@ import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
@@ -116,6 +114,16 @@ import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings'
import {LikeNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikeNotificationSettings'
import {LikesOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
import {MentionNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MentionNotificationSettings'
import {MiscellaneousNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings'
import {NewFollowerNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/NewFollowerNotificationSettings'
import {QuoteNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/QuoteNotificationSettings'
import {ReplyNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ReplyNotificationSettings'
import {RepostNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostNotificationSettings'
import {RepostsOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings'
import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from '#/screens/Settings/Settings'
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
@@ -436,6 +444,86 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => NotificationSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}}
/>
<Stack.Screen
name="ReplyNotificationSettings"
getComponent={() => ReplyNotificationSettingsScreen}
options={{
title: title(msg`Reply notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="MentionNotificationSettings"
getComponent={() => MentionNotificationSettingsScreen}
options={{
title: title(msg`Mention notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="QuoteNotificationSettings"
getComponent={() => QuoteNotificationSettingsScreen}
options={{
title: title(msg`Quote notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="LikeNotificationSettings"
getComponent={() => LikeNotificationSettingsScreen}
options={{
title: title(msg`Like notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="RepostNotificationSettings"
getComponent={() => RepostNotificationSettingsScreen}
options={{
title: title(msg`Repost notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="NewFollowerNotificationSettings"
getComponent={() => NewFollowerNotificationSettingsScreen}
options={{
title: title(msg`New follower notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="LikesOnRepostsNotificationSettings"
getComponent={() => LikesOnRepostsNotificationSettingsScreen}
options={{
title: title(msg`Likes of your reposts notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="RepostsOnRepostsNotificationSettings"
getComponent={() => RepostsOnRepostsNotificationSettingsScreen}
options={{
title: title(msg`Reposts of your reposts notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="ActivityNotificationSettings"
getComponent={() => ActivityNotificationSettingsScreen}
options={{
title: title(msg`Activity notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="MiscellaneousNotificationSettings"
getComponent={() => MiscellaneousNotificationSettingsScreen}
options={{
title: title(msg`Miscellaneous notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="ContentAndMediaSettings"
getComponent={() => ContentAndMediaSettingsScreen}
@@ -489,11 +577,6 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesConversationSettingsScreen}
options={{title: title(msg`Group chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesJoinRequests"
getComponent={() => MessagesJoinRequestsScreen}
options={{title: title(msg`Requests to join`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesSettings"
getComponent={() => MessagesSettingsScreen}
@@ -804,19 +887,6 @@ const LINKING = {
return buildStateObject('Flat', 'Home', params)
}
// Chat invite URLs (`/chat/:code`) are handled by `useIntentHandler`, which
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
// path to Home so the dialog overlays Home instead of NotFound. On native,
// react-navigation strips the `bluesky://` prefix and passes the path
// without a leading slash, so normalize before matching.
const normalizedPath = path.startsWith('/') ? path : `/${path}`
if (CHAT_INVITE_CODE_REGEX.test(normalizedPath.split('?')[0])) {
if (IS_NATIVE) {
return buildStateObject('HomeTab', 'Home', params)
}
return buildStateObject('Flat', 'Home', params)
}
if (IS_NATIVE) {
if (name === 'Search') {
return buildStateObject('SearchTab', 'Search', params)
@@ -846,7 +916,6 @@ const LINKING = {
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const ax = useAnalytics()
// eslint-disable-next-line react-compiler/react-compiler
const notyLogger = ax.logger.useChild(ax.logger.Context.Notifications)
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
const {currentAccount, accounts} = useSession()
@@ -874,7 +943,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const account = accounts.find(a => a.did === payload.recipientDid)
if (account) {
void onPressSwitchAccount(account, 'Notification')
onPressSwitchAccount(account, 'Notification')
} else {
setShowLoggedOut(true)
}
@@ -886,7 +955,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
// chat-added-to-group routes to the convo because the recipient was
// just added and now has access.
// @ts-expect-error nested navigators aren't typed -sfn
void navigate('MessagesTab', {
navigate('MessagesTab', {
screen: 'Messages',
params: {
pushToConversation: payload.convoId,
@@ -943,7 +1012,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
} else if (path) {
const [screen, params] = router.matchPath(path)
// @ts-expect-error nested navigators aren't typed -sfn
void navigate('HomeTab', {screen, params})
navigate('HomeTab', {screen, params})
notyLogger.debug(`handlePushNotificationEntry: navigate`, {
screen,
params,
@@ -1049,7 +1118,7 @@ function navigate<K extends keyof AllNavigatorParams>(
}
navigationRef.addListener('state', handler)
// @ts-ignore I don't know what would make typescript happy but I have a life -prf
// @ts-ignore I dont know what would make typescript happy but I have a life -prf
navigationRef.navigate(name, params)
}),
timeout(1e3),
@@ -1062,7 +1131,7 @@ function resetToTab(
tabName: 'HomeTab' | 'SearchTab' | 'MessagesTab' | 'NotificationsTab',
) {
if (navigationRef.isReady()) {
void navigate(tabName)
navigate(tabName)
if (navigationRef.canGoBack()) {
navigationRef.dispatch(StackActions.popToTop()) //we need to check .canGoBack() before calling it
}
+1 -1
View File
@@ -1,3 +1,3 @@
export const prefetchAgeAssuranceServerData = () => {}
export const prefetchAgeAssuranceData = () => {}
export const setBirthdateForDid = () => {}
export const setCreatedAtForDid = () => {}
@@ -32,7 +32,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {
isLegacyBirthdateBug,
@@ -53,7 +53,7 @@ export function NoAccessScreen() {
const birthdateControl = useDialogControl()
const deactivateAccountControl = useDialogControl()
const deleteAccountControl = useDialogControl()
const {metadata} = useAgeAssuranceServerDataContext()
const {data} = useAgeAssuranceDataContext()
const region = useAgeAssuranceRegionConfig()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
const {logoutCurrentAccount} = useSessionApi()
@@ -62,15 +62,15 @@ export function NoAccessScreen() {
const aa = useAgeAssurance()
const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region
const hasDeclaredAge = metadata?.declaredAge !== undefined
const hasDeclaredAge = data?.declaredAge !== undefined
const canUpdateBirthday =
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
useEffect(() => {
// just counting overall hits here
ax.metric(`blockedGeoOverlay:shown`, {})
ax.metric(`ageAssurance:noAccessScreen:shown`, {
accountCreatedAt: metadata?.accountCreatedAt || 'unknown',
accountCreatedAt: data?.accountCreatedAt || 'unknown',
isAARegion,
hasDeclaredAge,
canUpdateBirthday,
@@ -394,11 +394,11 @@ function AccessSection() {
<Trans>
Is your location not accurate?{' '}
<SimpleInlineLinkText
label={_(msg`Update your location`)}
label={_(msg`Confirm your location`)}
{...createStaticClick(() => {
locationControl.open()
})}>
Tap here to update your location with GPS.
Tap here to confirm your location.
</SimpleInlineLinkText>{' '}
</Trans>
</Admonition>
-28
View File
@@ -1,28 +0,0 @@
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
} from '@atproto/api'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
/**
* Minimum age required to access the app at all.
*/
export const MIN_ACCESS_AGE = 13
export const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
countryCode: '*',
regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}

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