Merge remote-tracking branch 'origin/main' into APP-2833

* origin/main: (94 commits)
  Create moderation inbox menu item and feature gate (#11552)
  Add OP thread numbering to feed posts (#11472)
  [APP-2864] Use hydrated known likers (#11541)
  Enable React Native strict TypeScript API (#11459)
  Nightly source-language update
  Bump the actions group with 2 updates (#11537)
  Update tech stack versions and fix markdown in CLAUDE.md  (#11530)
  Send an integer exp for video upload service auth tokens (#11529)
  Fix iOS message input height after sending (#11533)
  Revert "Send beta user state with AppView requests (#11474)" (#11510)
  Overfetch trending topics before filtering (#11477)
  [APP-2922] Fix cached starter pack navigation (#11531)
  Nightly source-language update
  Explain trending topics in options menu (#11521)
  Nightly source-language update
  Adds a small padding for labeller avatars in group chat 'Add people' selector (#11422)
  [AA] Set `maxAge: Infinity` on age assurance cache (#11526)
  Reland "Bump React Compiler to stable 1.0.0" (#11525)
  Fix hairline divider not visible (#11518)
  Back to v1.132.0 (#11522)
  ...
This commit is contained in:
Eric Bailey
2026-08-25 16:58:07 -05:00
1169 changed files with 159530 additions and 128659 deletions
+26 -3
View File
@@ -1,13 +1,15 @@
version: 2 version: 2
# Dependabot auto-update config. # Dependabot auto-update config.
# #
# Cooldown (7 days) is the point of this config: it delays version-update # npm and GitHub Actions use a 7-day cooldown so newly published versions age
# PRs until a newly-published version has aged. Supply-chain attacks like # before a PR opens. Supply-chain attacks like
# the tanstack Shai-Hulud compromise (2026-05-11) live minutes-to-hours # the tanstack Shai-Hulud compromise (2026-05-11) live minutes-to-hours
# before the registry yanks them; a 7-day cooldown keeps poisoned # before the registry yanks them; a 7-day cooldown keeps poisoned
# versions out of our lockfiles. # versions out of our lockfiles.
# #
# Security updates bypass cooldown and continue to flow immediately. See: # Security updates bypass cooldown and continue to flow immediately. Docker
# uses a shorter 3-day operational-freshness policy and does not support
# Dependabot security updates. See:
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown # 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 # Auto-merge is deliberately NOT enabled. Every dependabot PR gets human
@@ -42,3 +44,24 @@ updates:
actions: actions:
patterns: ["*"] patterns: ["*"]
update-types: [minor, patch] update-types: [minor, patch]
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
day: monday
time: "09:00"
timezone: America/Los_Angeles
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
docker-base-images:
group-by: dependency-name
patterns: ["*"]
ignore:
# Node 24 remains the active LTS line. Revisit Node 26 after it enters
# LTS in October 2026: https://github.com/bluesky-social/social-app/issues/11480
- dependency-name: node
update-types:
- version-update:semver-major
@@ -25,7 +25,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: 🔧 Setup Docker buildx - name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: 🔑 Log into registry ${{ env.REGISTRY }} - name: 🔑 Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -1,5 +1,12 @@
name: build-and-push-bskyweb-ghcr name: build-and-push-bskyweb-ghcr
on: on:
pull_request:
paths:
- Dockerfile
- Dockerfile.bskylink
- Dockerfile.bskyogcard
- Dockerfile.embedr
- .github/workflows/build-and-push-bskyweb-ghcr.yaml
push: push:
branches: branches:
- main - main
@@ -13,8 +20,40 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
verify-containers:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- image: bskyweb
file: Dockerfile
- image: bskylink
file: Dockerfile.bskylink
- image: bskyogcard
file: Dockerfile.bskyogcard
- image: embedr
file: Dockerfile.embedr
steps:
- name: ⬇️ Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build ${{ matrix.image }}
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ${{ matrix.file }}
platforms: linux/amd64
push: false
cache-from: type=gha,scope=${{ matrix.image }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}
bskyweb-container-ghcr: bskyweb-container-ghcr:
if: github.repository == 'bluesky-social/social-app' if: github.event_name == 'push' && github.repository == 'bluesky-social/social-app'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -26,7 +65,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: 🔧 Setup Docker buildx - name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: 🔑 Log into registry ${{ env.REGISTRY }} - name: 🔑 Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -25,7 +25,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: 🔧 Setup Docker buildx - name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: 🔑 Log into registry ${{ env.REGISTRY }} - name: 🔑 Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -25,7 +25,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: 🔧 Setup Docker buildx - name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: 🔑 Log into registry ${{ env.REGISTRY }} - name: 🔑 Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -25,7 +25,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: 🔧 Setup Docker buildx - name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: 🔑 Log into registry ${{ env.REGISTRY }} - name: 🔑 Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
fetch-depth: 1 fetch-depth: 1
- name: 🤖 Claude - name: 🤖 Claude
uses: anthropics/claude-code-action@1623c36729ac1cd5895198cded705a287de7db79 # v1.0.187 uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
env: env:
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
with: with:
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
fetch-depth: 1 fetch-depth: 1
- name: 🤖 Claude review - name: 🤖 Claude review
uses: anthropics/claude-code-action@1623c36729ac1cd5895198cded705a287de7db79 # v1.0.187 uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
env: env:
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
with: with:
+8 -1
View File
@@ -22,7 +22,14 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
job: job:
[lint, prettier, 'typecheck:ios', 'typecheck:android', 'typecheck:web'] [
lint,
prettier,
'lexicons:verify',
'typecheck:ios',
'typecheck:android',
'typecheck:web',
]
steps: steps:
- name: ⬇️ Check out Git repository - name: ⬇️ Check out Git repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+1 -8
View File
@@ -196,14 +196,7 @@ jobs:
with: with:
header: fingerprint-diff header: fingerprint-diff
message: | message: |
The Pull Request introduced fingerprint changes against the base commit: The Pull Request introduced native fingerprint changes against the base commit.
<details><summary>Fingerprint diff</summary>
```json
${{ steps.fingerprint.outputs.diff }}
```
</details>
--- ---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
+3
View File
@@ -117,6 +117,9 @@ src/locale/locales/**/messages.js
src/locale/locales/**/messages.mjs src/locale/locales/**/messages.mjs
src/locale/locales/**/messages.ts src/locale/locales/**/messages.ts
# generated lexicon schemas (pnpm lexicons:generate)
src/lexicons/
# local builds # local builds
*.apk *.apk
*.aab *.aab
-62
View File
@@ -1,62 +0,0 @@
/**
* Codemod to replace BskyAgent with AtpAgent
*
* Before:
* import {BskyAgent} from '@atproto/api`
* BskyAgent.appLabelers.includes(labeler)
*
* After:
* import {AtpAgent} from '@atproto/api`
* AtpAgent.appLabelers.includes(labeler)
*
* Handles import specifiers, type annotations, static member access
* (BskyAgent.configure), `extends BskyAgent`, and `new BskyAgent()`. Whole
* identifiers only, so names like `OpaqueBskyAgent` are left untouched.
*
* Usage: jscodeshift -t .jscodeshift/repo/bsky-agent.js <file-path>
* Example: jscodeshift -t .jscodeshift/repo/bsky-agent.js src/lib/moderation.ts
*/
/* eslint-disable */
export const parser = 'tsx'
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Replace every standalone `BskyAgent` identifier with `AtpAgent`. This
// covers imports, type references, member expressions, `extends`, and `new`.
root
.find(j.Identifier, {name: 'BskyAgent'})
.replaceWith(() => j.identifier('AtpAgent'))
// Renaming can leave a duplicate `AtpAgent` specifier on the @atproto/api
// import if the file already imported it. Dedupe by imported name, keeping
// the type-only modifier only if every duplicate was type-only.
root
.find(j.ImportDeclaration, {source: {value: '@atproto/api'}})
.forEach(path => {
const seen = new Map()
for (const spec of path.value.specifiers) {
if (spec.type !== 'ImportSpecifier') {
seen.set(Symbol(), spec)
continue
}
const name = spec.imported.name
const existing = seen.get(name)
if (!existing) {
seen.set(name, spec)
} else if (
existing.importKind === 'type' &&
spec.importKind !== 'type'
) {
// Prefer the value (non-type) import if either usage needs it.
seen.set(name, spec)
}
}
path.value.specifiers = Array.from(seen.values())
})
return root.toSource()
}
+1 -1
View File
@@ -1 +1 @@
24.18.0 24.19.0
+4 -5
View File
@@ -60,8 +60,10 @@
"bskyweb/**", "bskyweb/**",
"bskyembed/**", "bskyembed/**",
"bskyogcard/**", "bskyogcard/**",
"lint-rules/**",
"src/locale/locales/_build/**", "src/locale/locales/_build/**",
"src/locale/locales/**/*.js", "src/locale/locales/**/*.js",
"src/lexicons/**",
"*.e2e.ts", "*.e2e.ts",
"*.e2e.tsx", "*.e2e.tsx",
"eslint.config.mjs", "eslint.config.mjs",
@@ -238,7 +240,6 @@
} }
} }
], ],
"bsky-internal/use-exact-imports": "error",
"bsky-internal/use-prefixed-imports": "error", "bsky-internal/use-prefixed-imports": "error",
"bsky-internal/lingui-msg-rule": "error", "bsky-internal/lingui-msg-rule": "error",
"react/display-name": "error", "react/display-name": "error",
@@ -259,7 +260,6 @@
"react/no-unsafe": "off", "react/no-unsafe": "off",
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"react/hook-use-state": "warn", "react/hook-use-state": "warn",
"react-native/no-inline-styles": "off",
"react-native-a11y/has-accessibility-hint": "error", "react-native-a11y/has-accessibility-hint": "error",
"react-native-a11y/has-accessibility-props": "error", "react-native-a11y/has-accessibility-props": "error",
"react-native-a11y/has-valid-accessibility-actions": "error", "react-native-a11y/has-valid-accessibility-actions": "error",
@@ -274,7 +274,7 @@
"react-native-a11y/has-valid-accessibility-ignores-invert-colors": "error", "react-native-a11y/has-valid-accessibility-ignores-invert-colors": "error",
"react-native-a11y/has-valid-accessibility-live-region": "error", "react-native-a11y/has-valid-accessibility-live-region": "error",
"react-native-a11y/has-valid-important-for-accessibility": "error", "react-native-a11y/has-valid-important-for-accessibility": "error",
"react-compiler/react-compiler": "warn", "react/react-compiler": "warn",
"simple-import-sort/imports": [ "simple-import-sort/imports": [
"error", "error",
{ {
@@ -374,9 +374,7 @@
}, },
"jsPlugins": [ "jsPlugins": [
"eslint-plugin-bsky-internal", "eslint-plugin-bsky-internal",
"eslint-plugin-react-native",
"eslint-plugin-react-native-a11y", "eslint-plugin-react-native-a11y",
"eslint-plugin-react-compiler",
"eslint-plugin-simple-import-sort" "eslint-plugin-simple-import-sort"
], ],
"env": { "env": {
@@ -387,6 +385,7 @@
}, },
{ {
"files": [ "files": [
"bskylink/**/*.{js,jsx,ts,tsx}",
"bskyogcard/**/*.{js,jsx,ts,tsx}", "bskyogcard/**/*.{js,jsx,ts,tsx}",
"dev-env/**/*.{js,jsx,ts,tsx}" "dev-env/**/*.{js,jsx,ts,tsx}"
], ],
+1
View File
@@ -18,6 +18,7 @@
android android
ios ios
src/locale/locales src/locale/locales
src/lexicons
lib/react-compiler-runtime lib/react-compiler-runtime
bskyweb/static bskyweb/static
coverage coverage
+99 -122
View File
@@ -1,24 +1,38 @@
# Asset licensing # Asset licensing
The [MIT license](./LICENSE) in this repository covers our source code. It does not cover The [MIT license](./LICENSE) in this repository covers our source code. It does not cover every file in the tree.
every file in the tree.
Some of the images, icons, fonts, and brand assets here are licensed to Bluesky Social PBC Some of the images, icons, fonts, and brand assets here are licensed to Bluesky Social PBC by third parties, or are our trademarks, or are third-party trademarks. We cannot pass those rights on to you. This document identifies them and names who holds them.
by third parties, or are our trademarks, or are third-party trademarks. We cannot pass those
rights on to you. This document identifies them.
We should have written this down sooner. If you have already forked this repository and ## This is not a license change
shipped one of the assets listed below, we are not treating that as bad faith on your part —
the repository did not tell you, and that is our fault. Please work through the
[If you are forking](#if-you-are-forking) checklist when you can.
Assets are scoped by directory wherever possible, so that adding a file to a carved-out The MIT license on our source code is unchanged. This document records rights that Bluesky never held, and therefore could never have granted you.
directory does not require an edit here. Individual paths are listed only where an asset does
not sit in a dedicated directory.
**The rule:** if a file is listed in this document, the MIT license does not grant you rights We updated this file in August 2026 so the repository no longer carries a blanket MIT license with no asset carve-out and forking guidelines that ignored commissioned artwork.
to it. Everything in [Section 6](#6-third-party-assets-you-may-redistribute) is redistributable
on its own terms, which travel with the files. For the assets Bluesky itself owns, we are not treating anyone's past use as bad faith. For the rest we are not the rights holder. The tables below name them. If you have shipped one of these in a fork, the [If you are forking](#if-you-are-forking) checklist is the shortest path to a clean position.
## Summary
| Where | Rights holder | Our MIT license covers it? | If you fork |
|---|---|---|---|
| [`assets/illustrations/`](#1-commissioned-artwork--licensed-to-bluesky-only) | Owen D. Pomery, via Brilliant Artists Ltd | No | Replace |
| [`assets/icons/`](#2-licensed-icon-system--not-ours-to-pass-on) (top level), Central icon glyphs in `bskyembed/assets/` except the Starter Pack mark | Iconists (David & Storm GbR) | No | Source your own |
| [Bluesky marks](#3-bluesky-trademarks-and-brand-assets) — app icons, logos, favicons | Bluesky Social PBC | No | Replace |
| [`assets/kawaii.png`, `assets/kawaii_smol.png`](#4-community-and-contest-artwork--credited-but-not-ours-to-license) | [@sawaratsuki.bsky.social](https://bsky.app/profile/sawaratsuki.bsky.social) | No | Replace or remove |
| [`assets/icons/custom_logo_japan.svg`](#4-community-and-contest-artwork--credited-but-not-ours-to-license) | A Bluesky Japan logo contest entrant | No | Replace or remove |
| [`assets/icons/apple_logo.svg`](#5-third-party-trademarks) | Apple Inc. | No | Rests on your own basis |
| [`assets/icons/android_logo.svg`](#5-third-party-trademarks) | Google LLC | No | Rests on your own basis |
| [`assets/icons/community/`](#5-third-party-trademarks) | Leaflet, Offprint, pckt, Standard.site, Germ Network | No | Rests on your own basis |
| [`assets/fonts/inter/`](#6-third-party-assets-you-may-redistribute), Inter files in `bskyogcard/src/assets/fonts/` | The Inter Project Authors | Separate — OFL 1.1 | **Keep, with the notice** |
| [Noto fonts downloaded by `bskyogcard/scripts/install-fonts.ts`](#6-third-party-assets-you-may-redistribute) | Adobe, Google LLC, and The Noto Project Authors | Separate — OFL 1.1 | **Keep, with the notice** |
| [`assets/icons/flags/`](#6-third-party-assets-you-may-redistribute) | @catamphetamine | Separate — MIT | **Keep, with the license** |
| [`bskyweb/static/media/MaterialIcons.*.ttf`](#6-third-party-assets-you-may-redistribute) | Google, Inc. | Separate — Apache 2.0 | **Keep, with the notice** |
| [`assets/images/`](#7-product-imagery--provenance-being-documented) | Mixed, and not yet fully documented — see Section 7 | No | Replace or ship without |
Everything in [Section 6](#6-third-party-assets-you-may-redistribute) is already permissively licensed. It is the largest group of files listed here and it needs no action from you beyond keeping the notices in place.
Assets are scoped by directory wherever possible, so that adding a file to a carved-out directory does not require an edit here. Individual paths are listed only where an asset does not sit in a dedicated directory.
--- ---
@@ -26,54 +40,31 @@ on its own terms, which travel with the files.
**`assets/illustrations/`** **`assets/illustrations/`**
The landing-screen illustration, in light and dark variants, used by The landing-screen illustration, in light and dark variants (`assets/illustrations/illustration-mobile.png` and `assets/illustrations/illustration-mobile-dark.png`), used by `src/view/com/auth/SplashScreen.tsx`.
`src/view/com/auth/SplashScreen.tsx` (`illustration-mobile.png` and
`illustration-mobile-dark.png`).
This artwork was commissioned by Bluesky Social PBC from illustrator Owen D. Pomery, through **Rights holder: Owen D. Pomery**, represented by Brilliant Artists Ltd. Bluesky Social PBC commissioned the work and holds a usage license. Copyright remains with the artist. Our license is limited to Bluesky's own products and channels, is exclusive to us, and does not permit us to sublicense the artwork or to distribute modified versions of it.
his agent Brilliant Artists Ltd. Copyright remains with the artist. Our license is limited to
Bluesky's own products and channels, it is exclusive to us, and it does not permit us to **If you are forking this repository, replace these files.** Because our license is exclusive, the artwork is not available for separate third-party licensing while that license runs. Please do not approach the artist or his agent for permission — the constraint is our agreement, not their willingness. If you have already shipped it, contact us and we will help you sort it out rather than leaving you to guess.
sublicense the artwork or to distribute modified versions of it.
**If you are forking this repository, replace these files.** They are not available for
third-party licensing while our license runs, so please do not contact the artist or his agent
to request permission — the answer is constrained by our agreement, not by their willingness.
See [`assets/illustrations/README.md`](./assets/illustrations/README.md). See [`assets/illustrations/README.md`](./assets/illustrations/README.md).
## 2. Licensed icon system — not ours to pass on ## 2. Licensed icon system — not ours to pass on
**`assets/icons/` (top level), and the icon glyphs in `bskyembed/assets/`** **`assets/icons/` (top level), and the Central icon glyphs in `bskyembed/assets/`, except `bskyembed/assets/starterPack.svg`**
The user-interface glyphs at the top level of `assets/icons/`, plus copies of several in **Rights holder: Iconists (David & Storm GbR).** The user-interface glyphs come from their [Central icon system](https://iconists.co/central). Bluesky Social PBC licenses them for use in our own products. **That license is for our own use. It does not include the right to pass any rights to the icons on to you.**
`bskyembed/assets/`, come from the Central icon system by Iconists (David & Storm GbR). Bluesky
Social PBC licenses these icons for use in our own products. **That license is for our own use.
It does not include the right to pass any rights to the icons on to you.**
This is not us telling you that you cannot use these icons. It is us telling you that any right The fact that we have our own license does not mean that you cannot use these icons. It means that any right you have to use them has to come from Iconists, not us. Licenses are available from [iconists.co](https://iconists.co), and there are openly licensed alternatives if you prefer that.
you have to use them has to come from Iconists rather than from us, and that you should not
assume our MIT license gave you one. Licenses are available directly from
[iconists.co](https://iconists.co), and there are good openly licensed alternatives if you would
rather not buy one.
This section covers every file at the top level of `assets/icons/` **except** those named This section covers every file at the top level of `assets/icons/` **except** those named elsewhere in this document — specifically `assets/icons/logomark.svg`, `assets/icons/newskie.svg`, `assets/icons/verifiedCheck.svg`, `assets/icons/verifierCheck.svg`, `assets/icons/starterPack.svg`, `assets/icons/starterPack_stroke2_corner0_rounded.svg`, `assets/icons/custom_logo_japan.svg`, `assets/icons/apple_logo.svg`, and `assets/icons/android_logo.svg`. The `assets/icons/flags/` and `assets/icons/community/` subdirectories are covered by [Section 6](#6-third-party-assets-you-may-redistribute) and [Section 5](#5-third-party-trademarks) respectively.
elsewhere in this document — specifically `logomark.svg`, `newskie.svg`, `verifiedCheck.svg`,
`verifierCheck.svg`, `starterPack.svg`, `starterPack_stroke2_corner0_rounded.svg`, `custom_logo_japan.svg`,
`apple_logo.svg`, and `android_logo.svg`. The `assets/icons/flags/` and `assets/icons/community/`
subdirectories are covered by [Section 6](#6-third-party-assets-you-may-redistribute) and
[Section 5](#5-third-party-trademarks) respectively.
See [`assets/icons/README.md`](./assets/icons/README.md). See [`assets/icons/README.md`](./assets/icons/README.md).
## 3. Bluesky trademarks and brand assets ## 3. Bluesky trademarks and brand assets
Our name, logo, butterfly mark, logotype, and app icons are trademarks of Bluesky Social PBC. **Rights holder: Bluesky Social PBC.** Our name, logo, butterfly mark, logotype, and app icons are our trademarks. They are not licensed to you under the MIT license or by this document. Use of them is governed by our [Trademark Policy](https://bsky.social/about/support/trademarks) and [Brand Guidelines](https://bsky.social/about/support/branding).
They are not licensed to you under the MIT license or by this document. Use of them is governed
by our [Trademark Policy](https://bsky.social/about/support/trademarks) and [Brand Guidelines](https://bsky.social/about/support/branding).
You may refer to Bluesky by name to describe interoperability or origin — for example, "a client You may refer to Bluesky by name to describe interoperability or origin — for example, "a client for Bluesky," or "based on the Bluesky app." You may not use our marks as the identity of your own product or service, or in any way likely to suggest that Bluesky publishes, endorses, or supports it.
for Bluesky," or "based on the Bluesky app." You may not use our marks as the identity of your
own product or service, or in any way likely to suggest that Bluesky publishes, endorses, or
supports it.
- `assets/app-icons/` — all iOS and Android app icon variants, including the `.icon` bundles - `assets/app-icons/` — all iOS and Android app icon variants, including the `.icon` bundles
- `assets/favicon.png` - `assets/favicon.png`
@@ -89,120 +80,106 @@ supports it.
- `assets/icons/newskie.svg` - `assets/icons/newskie.svg`
- `assets/icons/verifiedCheck.svg` - `assets/icons/verifiedCheck.svg`
- `assets/icons/verifierCheck.svg` - `assets/icons/verifierCheck.svg`
- `assets/icons/starterPack.svg`, `assets/icons/starterPack_stroke2_corner0_rounded.svg` - `assets/icons/starterPack.svg`
- `assets/icons/starterPack_stroke2_corner0_rounded.svg`
- `bskyembed/assets/logo.svg` - `bskyembed/assets/logo.svg`
- `bskyembed/assets/logo_full_name.svg` - `bskyembed/assets/logo_full_name.svg`
- `bskyweb/static/favicon.png`, `favicon-16x16.png`, `favicon-32x32.png` - `bskyembed/assets/starterPack.svg`
- `bskyweb/static/favicon.png`
- `bskyweb/static/favicon-16x16.png`
- `bskyweb/static/favicon-32x32.png`
- `bskyweb/static/apple-touch-icon.png` - `bskyweb/static/apple-touch-icon.png`
- `bskyweb/static/safari-pinned-tab.svg` - `bskyweb/static/safari-pinned-tab.svg`
- `bskyweb/static/social-card-default.png`, `bskyweb/static/social-card-default-gradient.png` - `bskyweb/static/social-card-default.png`
- `bskyweb/embedr-static/favicon.png`, `favicon-16x16.png`, `favicon-32x32.png` - `bskyweb/static/social-card-default-gradient.png`
- `bskyweb/embedr-static/favicon.png`
- `bskyweb/embedr-static/favicon-16x16.png`
- `bskyweb/embedr-static/favicon-32x32.png`
- `modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/` - `modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/`
- Inline vector path data in `src/view/icons/Logo.tsx` and `src/view/icons/Logotype.tsx` - Inline vector path data in `src/view/icons/Logo.tsx`, `src/view/icons/Logomark.tsx`, `src/view/icons/LogomarkWithType.tsx`, and `src/view/icons/Logotype.tsx`
We are not asking anyone to remove these files from the repository. They are here because the These files stay in this repository because the app needs them to build. **If you fork, replace them with your own** — that is the one thing this section asks of you. Shipping an app that looks like Bluesky is also a problem under the app stores' own rules on copycat apps, quite apart from trademark.
app needs them to build. Replacing them is a forker's responsibility, not ours.
## 4. Community and contest artwork — credited, but not ours to license ## 4. Community and contest artwork — credited, but not ours to license
These are third-party artworks that appear in the app with attribution. We hold no license that These are third-party artworks that appear in the app with attribution. We hold no license that lets us pass rights to them on to you.
lets us pass rights to them on to you.
- `assets/kawaii.png`, `assets/kawaii_smol.png` — logo by - `assets/kawaii.png` and `assets/kawaii_smol.png` — **rights holder:
[@sawaratsuki.bsky.social](https://bsky.app/profile/sawaratsuki.bsky.social), shown as an [@sawaratsuki.bsky.social](https://bsky.app/profile/sawaratsuki.bsky.social)**. Shown as an opt-in variant and credited in `src/view/shell/Drawer.tsx` and `src/view/shell/desktop/RightNav.tsx`.
opt-in variant and credited in `src/view/shell/Drawer.tsx` and - `assets/icons/custom_logo_japan.svg` — **rights holder: the entrant who won the Bluesky Japan logo contest.**
`src/view/shell/desktop/RightNav.tsx`
- `assets/icons/custom_logo_japan.svg` — the winning entry from the Bluesky Japan logo contest,
created by a contest entrant
Replace these if you fork. If you want to use them, that is a conversation with the artist, Replace or remove these if you fork. If you want to use them, contact the artist.
not with us.
## 5. Third-party trademarks ## 5. Third-party trademarks
These marks belong to other companies. We include them to identify their services in our UI — These marks belong to other companies. We include them to identify their services in our UI — sign-in buttons, store badges, and links to third-party applications. We are neither granting nor withholding permission, because it is not ours to give. Your use of them rests on your own nominative-use basis or on permission from the mark owner.
sign-in buttons, store badges, and links to third-party applications. We are neither granting
nor withholding permission, because it is not ours to give. Your use of them rests on your own
nominative-use basis or on permission from the mark owner.
- `assets/icons/apple_logo.svg` — Apple Inc. - `assets/icons/apple_logo.svg` — **Apple Inc.**
- `assets/icons/android_logo.svg` — Google LLC - `assets/icons/android_logo.svg` — **Google LLC**
- `assets/icons/community/` — Leaflet, Offprint, pckt (`pckt.svg`, `pckt-full.svg`), - `assets/icons/community/leaflet.svg` — **Leaflet**
Standard.site, and Germ Network (`germ_logo.webp`) - `assets/icons/community/offprint.svg` — **Offprint**
- `assets/icons/community/pckt.svg` and `assets/icons/community/pckt-full.svg` — **pckt**
- `assets/icons/community/standard-site.svg` — **Standard.site**
- `assets/icons/community/germ_logo.webp` — **Germ Network**
Apple's and Google's marks in particular carry their own brand guidelines governing size, Apple's and Google's marks in particular carry their own brand guidelines governing size, spacing, and permitted contexts. If you ship a sign-in button or a store badge, follow their guidelines.
spacing, and permitted contexts. If you ship a sign-in button or a store badge, follow theirs,
not ours.
## 6. Third-party assets you may redistribute ## 6. Third-party assets you may redistribute
These are licensed on terms that permit redistribution. Nothing in this document restricts them. These are licensed on terms that permit redistribution. Nothing in this document restricts them. We list them so you know they are safe, and so you know to carry their notices. This is the largest group of assets in this document.
Their license text travels with the files, and you must keep it there.
| Asset | Path | License | Notice | | Asset | Path | Rights holder | License | Notice |
|---|---|---|---| |---|---|---|---|---|
| Inter typeface | `assets/fonts/inter/` | SIL Open Font License 1.1 | [`OFL.txt`](./assets/fonts/inter/OFL.txt) | | Inter typeface | `assets/fonts/inter/`, `bskyogcard/src/assets/fonts/Inter-*.ttf` | The Inter Project Authors | SIL Open Font License 1.1 | [`OFL.txt`](./assets/fonts/inter/OFL.txt) |
| Inter typeface (OG card service) | `bskyogcard/src/assets/fonts/` | SIL Open Font License 1.1 | [`README.md`](./bskyogcard/src/assets/fonts/README.md) | | Noto Sans families (OG card service) | Downloaded by `bskyogcard/scripts/install-fonts.ts` | Adobe, Google LLC, and The Noto Project Authors | SIL Open Font License 1.1 | [`README.md`](./bskyogcard/src/assets/fonts/README.md) |
| country-flag-icons | `assets/icons/flags/` | MIT, © @catamphetamine | [`README.md`](./assets/icons/flags/README.md) | | country-flag-icons | `assets/icons/flags/` | @catamphetamine | MIT | [`LICENSE`](./assets/icons/flags/LICENSE) |
| Material Icons | `bskyweb/static/media/MaterialIcons.*.ttf` | Apache License 2.0 | [`NOTICE.md`](./NOTICE.md) | | Material Icons | `bskyweb/static/media/MaterialIcons.*.ttf` | Google, Inc. | Apache License 2.0 | [`NOTICE.md`](./NOTICE.md) |
Build output under `bskyweb/static/media/` also contains compiled Inter files. They are the same Build output under `bskyweb/static/media/` also contains compiled Inter files. They are the same OFL-licensed typeface, emitted by the web build. The bundled Inter license does not designate a Reserved Font Name.
OFL-licensed typeface, emitted by the web build.
**One thing to watch on Inter:** OFL 1.1 includes a Reserved Font Name provision. If you modify The OG card build downloads Noto Sans fonts into `bskyogcard/src/assets/fonts/` and copies them into its build output. Their copyright notices and OFL text are in [`bskyogcard/src/assets/fonts/OFL-NOTO.txt`](./bskyogcard/src/assets/fonts/OFL-NOTO.txt). The CJK fonts reserve the name "Source."
or subset the font, the result cannot be distributed under the name "Inter."
See [`NOTICE.md`](./NOTICE.md) for the consolidated third-party notices. See [`NOTICE.md`](./NOTICE.md) for the consolidated third-party notices.
## 7. Product imagery — treat as not licensed to you ## 7. Product imagery — provenance being documented
**`assets/images/`** **`assets/images/`**
Product illustration and announcement imagery — onboarding value-prop art, chat backgrounds, Product illustration and announcement imagery — onboarding art, chat backgrounds, feature announcement graphics, and similar.
feature announcement graphics, and similar.
**Treat everything in this directory as outside the MIT license and not licensed for your use.** **Rights holder: mixed, and we have not finished documenting it.** Some of this is Bluesky's own work. Some was commissioned from outside illustrators, on terms that do not let us pass rights on. We are working out which is which.
Some of it is commissioned work. Rather than have you guess file by file which is which, we are
drawing the line at the directory.
If you are forking, replace these or ship without them. Until we have, **treat the whole directory as outside the MIT license and not licensed for your use.**
See [`assets/images/README.md`](./assets/images/README.md).
When this is resolved, one of two things will happen: this section will name the rights holder for each file, or the directory will be split so that the boundary itself carries the answer. If you need a specific file's status before then, ask us and we will find out.
If you are forking, replace these or ship without them. See [`assets/images/README.md`](./assets/images/README.md).
--- ---
## If you are forking ## If you are forking
You have our blessing to fork this application. To do it cleanly: You have our blessing to fork this application. These steps map one-to-one to the sections above.
1. **Replace `assets/illustrations/`.** Commissioned artwork, licensed to Bluesky only. See 1. **Replace `assets/illustrations/`** — commissioned artwork, licensed to Bluesky only. [Section 1](#1-commissioned-artwork--licensed-to-bluesky-only)
[Section 1](#1-commissioned-artwork--licensed-to-bluesky-only). 2. **Source your own UI icons** — the glyph set in `assets/icons/` is licensed to us for our own use. [Section 2](#2-licensed-icon-system--not-ours-to-pass-on)
2. **Source your own UI icons.** The glyph set in `assets/icons/` is licensed to us for our own 3. **Replace the Bluesky marks** — app icons, favicons, logo files, and the inline logo paths in `src/view/icons/`. [Section 3](#3-bluesky-trademarks-and-brand-assets)
use. See [Section 2](#2-licensed-icon-system--not-ours-to-pass-on). 4. **Replace or remove the community and contest artwork.** [Section 4](#4-community-and-contest-artwork--credited-but-not-ours-to-license)
3. **Replace `assets/images/`.** Treat as not licensed to you. See 5. **Check your own position on the third-party marks.** [Section 5](#5-third-party-trademarks)
[Section 7](#7-product-imagery--treat-as-not-licensed-to-you). 6. **Keep the assets you may redistribute, and keep their notices with them.** [Section 6](#6-third-party-assets-you-may-redistribute)
4. **Replace the Bluesky marks** listed in [Section 3](#3-bluesky-trademarks-and-brand-assets) — 7. **Replace `assets/images/`, or ship without it.** [Section 7](#7-product-imagery--provenance-being-documented)
app icons, favicons, logo files, and the inline logo paths in `src/view/icons/`.
5. **Replace or remove the community artwork** in
[Section 4](#4-community-and-contest-artwork--credited-but-not-ours-to-license).
6. **Check your own position on the third-party marks** in
[Section 5](#5-third-party-trademarks).
7. **Keep the license notices** for the assets in
[Section 6](#6-third-party-assets-you-may-redistribute).
8. **Change your branding, support links, and analytics** as described in the
[Forking guidelines](./README.md#forking-guidelines).
This list is about licensing. The [Forking guidelines](./README.md#forking-guidelines) in the Then change your branding, support links, and analytics as described in the [Forking guidelines](./README.md#forking-guidelines). That part is not about licensing — it is what makes a fork clearly distinguishable from Bluesky, which matters both for your users and for app store review.
README cover the rest of what makes a fork clearly distinguishable from Bluesky, which matters
both for your users and for app store review.
## Questions ## Questions
If something in this repository looks like it should be on this list and is not, or if you are If something in this repository looks like it should be on this list and is not, if a rights holder named here is wrong, or if you are unsure whether an asset is covered, open an issue or email [atmosphere@blueskyweb.xyz](mailto:atmosphere@blueskyweb.xyz).
unsure whether an asset is covered, open an issue or email us and we will sort it out. We would
much rather answer the question than have someone guess. ## History
- **August 2026** — this document added, along with [`NOTICE.md`](./NOTICE.md), per-directory notices, and the required Apache 2.0 and OFL license texts. It documents pre-existing rights; it does not change the [MIT license](./LICENSE) or relicense any file.
- **Before that** — the repository carried a blanket MIT license with no asset carve-out, and the forking guidelines did not mention commissioned artwork, trademarks, or licensed icons.
--- ---
*Last reviewed: August 2026. This document describes the licensing position of assets in this *This document describes the licensing position of assets in this repository. It is not a grant of rights, and it does not modify the [MIT license](./LICENSE) as it applies to source code.*
repository. It is not a grant of rights, and it does not modify the [MIT license](./LICENSE) as
it applies to source code.*
+3 -3
View File
@@ -8,8 +8,8 @@ Bluesky Social is a cross-platform social media application built with React Nat
**Tech Stack:** **Tech Stack:**
- React 19.1 - React 19.2
- React Native 0.81 with Expo 54 - React Native 0.86 with Expo 57
- TypeScript 7 - TypeScript 7
- React Navigation 7 for routing - React Navigation 7 for routing
- TanStack Query (React Query) for data fetching - TanStack Query (React Query) for data fetching
@@ -561,7 +561,7 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful 1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
2. **Translations**: Wrap ALL user-facing strings with ` `l` `` or `<Trans>` 2. **Translations**: Wrap ALL user-facing strings with the `` l`…` `` macro or the `<Trans>` component
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles 3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
+4 -3
View File
@@ -1,4 +1,4 @@
FROM node:24.18.0-alpine3.23 AS build FROM node:24.19.0-alpine3.23 AS build
# Move files into the image and install # Move files into the image and install
WORKDIR /app WORKDIR /app
@@ -14,7 +14,7 @@ RUN yarn build
RUN yarn install --production --ignore-scripts --prefer-offline RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size # Uses assets from build stage to reduce build size
FROM node:24.18.0-alpine3.23 FROM node:24.19.0-alpine3.23
RUN apk add --update dumb-init RUN apk add --update dumb-init
@@ -26,8 +26,9 @@ COPY --from=build /app /app
RUN mkdir /app/data && chown node /app/data RUN mkdir /app/data && chown node /app/data
VOLUME /app/data VOLUME /app/data
EXPOSE 3000 EXPOSE 3000 9090
ENV LINK_PORT=3000 ENV LINK_PORT=3000
ENV LINK_METRICS_PORT=9090
ENV NODE_ENV=production ENV NODE_ENV=production
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user # https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:24.18.0-alpine3.23 AS build FROM node:24.19.0-alpine3.23 AS build
# Tells pnpm to run non-interactively (needed for install/script steps) # Tells pnpm to run non-interactively (needed for install/script steps)
ENV CI=true ENV CI=true
@@ -19,7 +19,7 @@ RUN pnpm install-fonts && pnpm build
RUN pnpm install --prod --ignore-scripts --prefer-offline RUN pnpm install --prod --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size # Uses assets from build stage to reduce build size
FROM node:24.18.0-alpine3.23 FROM node:24.19.0-alpine3.23
RUN apk add --update dumb-init RUN apk add --update dumb-init
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
# Node # Node
ENV NODE_VERSION=24.18.0 ENV NODE_VERSION=24.19.0
ENV NVM_DIR=/usr/share/nvm ENV NVM_DIR=/usr/share/nvm
# Go # Go
+23 -23
View File
@@ -1,8 +1,6 @@
# Third-party notices # Third-party notices
This file collects the attribution and license notices that third-party components in this This file collects the attribution and license notices that third-party components in this repository require us to carry. It is separate from [`ASSETS.md`](./ASSETS.md), which describes which assets our [MIT license](./LICENSE) does and does not cover.
repository require us to carry. It is separate from [`ASSETS.md`](./ASSETS.md), which describes
which assets our [MIT license](./LICENSE) does and does not cover.
If you distribute this software or a fork of it, these notices need to travel with it. If you distribute this software or a fork of it, these notices need to travel with it.
@@ -17,39 +15,43 @@ If you distribute this software or a fork of it, these notices need to travel wi
Copyright 2018 Google, Inc. All Rights Reserved. Copyright 2018 Google, Inc. All Rights Reserved.
``` ```
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
or implied. See the License for the specific language governing permissions and limitations under
the License.
*This font is emitted into the web build by `@expo/vector-icons`, which the Expo toolchain pulls *This font is emitted into the web build by `@expo/vector-icons`, which the Expo toolchain pulls in transitively. It is a distributed artifact rather than a source asset, which is why the notice lives here rather than beside the file.*
in transitively. It is a distributed artifact rather than a source asset, which is why the notice
lives here rather than beside the file.*
## Inter ## Inter
**Paths:** `assets/fonts/inter/`, `bskyogcard/src/assets/fonts/`, and compiled copies under **Paths:** `assets/fonts/inter/`, `bskyogcard/src/assets/fonts/Inter-*.ttf`, and compiled copies under `bskyweb/static/media/`
`bskyweb/static/media/` **License:** SIL Open Font License, Version 1.1 — full text at [`assets/fonts/inter/OFL.txt`](./assets/fonts/inter/OFL.txt)
**License:** SIL Open Font License, Version 1.1 — full text at
[`assets/fonts/inter/OFL.txt`](./assets/fonts/inter/OFL.txt)
``` ```
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
``` ```
Inter is licensed under the SIL Open Font License, Version 1.1. The license includes a Reserved Inter is licensed under the SIL Open Font License, Version 1.1. The bundled license does not designate a Reserved Font Name.
Font Name provision: a modified or subsetted version of the font may not be distributed under the
name "Inter." ## Noto Sans
**Generated by:** `bskyogcard/scripts/install-fonts.ts`, into `bskyogcard/src/assets/fonts/` and the OG card build output
**License:** SIL Open Font License, Version 1.1 — full text at [`bskyogcard/src/assets/fonts/OFL-NOTO.txt`](./bskyogcard/src/assets/fonts/OFL-NOTO.txt)
```
(c) 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
Copyright 2015-2020 Google LLC. All Rights Reserved.
Copyright 2024 The Noto Project Authors (https://github.com/notofonts/hebrew)
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/thai)
```
The OG card build downloads Noto Sans Arabic, Hebrew, HK, JP, KR, SC, TC, and Thai from Fontsource. All are licensed under OFL 1.1. The CJK families reserve the name "Source."
## country-flag-icons ## country-flag-icons
**Path:** `assets/icons/flags/` **Path:** `assets/icons/flags/`
**License:** MIT — see [`assets/icons/flags/README.md`](./assets/icons/flags/README.md) **License:** MIT — full text at [`assets/icons/flags/LICENSE`](./assets/icons/flags/LICENSE)
``` ```
Copyright (c) 2020 @catamphetamine <purecatamphetamine@gmail.com> Copyright (c) 2020 @catamphetamine <purecatamphetamine@gmail.com>
@@ -57,6 +59,4 @@ Copyright (c) 2020 @catamphetamine <purecatamphetamine@gmail.com>
--- ---
For the licensing position of assets that are **not** covered by our MIT license — commissioned For the licensing position of assets that are **not** covered by our MIT license — commissioned artwork, the licensed icon system, Bluesky trademarks, third-party marks, and the product imagery in `assets/images/` — see [`ASSETS.md`](./ASSETS.md), which names known rights holders and identifies the product imagery whose provenance is still being documented.
artwork, the licensed icon system, Bluesky trademarks, and third-party marks — see
[`ASSETS.md`](./ASSETS.md).
+1 -1
View File
@@ -1,4 +1,4 @@
import {RichText} from '@atproto/api' import {RichText} from '@bsky/sdk/richtext'
import {i18n} from '@lingui/core' import {i18n} from '@lingui/core'
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player' import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
+1 -5
View File
@@ -184,10 +184,6 @@ module.exports = function (_config) {
androidStatusBar: { androidStatusBar: {
barStyle: 'light-content', barStyle: 'light-content',
}, },
// Dark nav bar in light mode is better than light nav bar in dark mode
androidNavigationBar: {
barStyle: 'light-content',
},
android: { android: {
icon: './assets/app-icons/android_icon_default_next.png', icon: './assets/app-icons/android_icon_default_next.png',
adaptiveIcon: { adaptiveIcon: {
@@ -261,7 +257,7 @@ module.exports = function (_config) {
'expo-build-properties', 'expo-build-properties',
{ {
ios: { ios: {
deploymentTarget: '15.1', deploymentTarget: '16.4',
buildReactNativeFromSource: true, buildReactNativeFromSource: true,
ccacheEnabled: IS_DEV, ccacheEnabled: IS_DEV,
cxxLanguageStandard: 'c++23', cxxLanguageStandard: 'c++23',
+7 -12
View File
@@ -1,16 +1,11 @@
The app icons in this directory are trademarks of Bluesky Social **Rights holder: Bluesky Social PBC.**
PBC — the butterfly mark and its variants, as shipped to the iOS App Store and Google Play.
**These are not covered by the [MIT license](../../LICENSE) that applies to the rest of this The app icons in this directory are our trademarks — the butterfly mark and its variants, as shipped to the iOS App Store and Google Play.
repository.** Use of Bluesky's marks is governed by our Trademark Policy and Brand Guidelines,
not by the license on our source code.
You may say that your app is a client for Bluesky, or that it is based on the Bluesky app. You **These are not covered by the [MIT license](../../LICENSE) that applies to the rest of this repository.** Use of Bluesky's marks is governed by our [Trademark Policy](https://bsky.social/about/support/trademarks) and [Brand Guidelines](https://bsky.social/about/support/branding), not by the license on our source code.
may not use our marks as the identity of your own product, or in any way likely to suggest that
Bluesky publishes, endorses, or supports it.
If you are forking this repository, replace these files with your own icons. Shipping an app that You may say that your app is a client for Bluesky, or that it is based on the Bluesky app. You may not use our marks as the identity of your own product, or in any way likely to suggest that Bluesky publishes, endorses, or supports it.
looks like Bluesky is also a problem under the app stores' own rules on copycat apps, quite apart
from trademark.
See [`ASSETS.md`](../../ASSETS.md) for the full asset licensing picture. If you are forking this repository, replace these files with your own icons. Shipping an app that looks like Bluesky is also a problem under the app stores' own rules on copycat apps, quite apart from trademark.
See [`ASSETS.md`](../../ASSETS.md#3-bluesky-trademarks-and-brand-assets) for the full asset licensing picture.
+6 -8
View File
@@ -1,15 +1,13 @@
[Inter](https://rsms.me/inter/) is a typeface by Rasmus **Rights holder: The Inter Project Authors.**
Andersson, licensed under the SIL Open Font License, Version 1.1.
The full license text is in [`OFL.txt`](./OFL.txt) and must travel with these files. [Inter](https://rsms.me/inter/) is a typeface by Rasmus Andersson, licensed under the SIL Open Font License, Version 1.1.
``` ```
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
``` ```
**You may redistribute these files.** Nothing in [`ASSETS.md`](../../../ASSETS.md) restricts them The full license text is in [`OFL.txt`](./OFL.txt) and must travel with these files.
— they are here because the OFL requires the license text to accompany the font, not because they
are carved out of our [MIT license](../../../LICENSE).
**One thing to watch:** OFL 1.1 includes a Reserved Font Name provision. If you modify or subset **You may redistribute these files under the OFL.** They are separately licensed and are not covered by Bluesky's [MIT license](../../../LICENSE). Keep [`OFL.txt`](./OFL.txt) with redistributed copies.
the font, you may not distribute the result under the name "Inter."
The bundled OFL does not designate a Reserved Font Name.
+13 -18
View File
@@ -1,23 +1,18 @@
The user-interface glyphs in this directory are from the **Rights holder: Iconists (David & Storm GbR).**
[Central icon system](https://iconists.co/central) by Iconists (David & Storm GbR). Bluesky
Social PBC licenses them for use in our own products. **That license is for our own use, and it
does not extend to you.**
**These icons are not covered by the [MIT license](../../LICENSE) that applies to the rest of The user-interface glyphs in this directory come from their [Central icon system](https://iconists.co/central). Bluesky Social PBC licenses them for use in our own products. **That license is for our own use, and it does not extend to you.**
this repository.** If you want to use them, buy a license from
[iconists.co](https://iconists.co). There are also good openly licensed icon sets if you would **These icons are not covered by the [MIT license](../../LICENSE) that applies to the rest of this repository.** The fact that we have our own license does not mean that you cannot use these icons. It means that any right you have to use them has to come from Iconists, not us. Licenses are available from [iconists.co](https://iconists.co), and there are openly licensed alternatives if you prefer that.
rather not.
This notice covers the SVG files at the top level of this directory. It does not cover: This notice covers the SVG files at the top level of this directory. It does not cover:
- `flags/` — country-flag-icons, MIT licensed, see [`flags/README.md`](./flags/README.md) | Not covered here | Rights holder | See |
- `community/` — third-party service marks, see [`community/README.md`](./community/README.md) |---|---|---|
- `logomark.svg`, `newskie.svg`, `verifiedCheck.svg`, `verifierCheck.svg`, `starterPack.svg`, | `flags/` | @catamphetamine, MIT licensed | [`flags/README.md`](./flags/README.md) |
`starterPack_stroke2_corner0_rounded.svg` — Bluesky trademarks and brand assets | `community/` | Third-party services | [`community/README.md`](./community/README.md) |
- `custom_logo_japan.svg` — artwork by a Bluesky Japan logo contest entrant | `logomark.svg`, `newskie.svg`, `verifiedCheck.svg`, `verifierCheck.svg`, `starterPack.svg`, `starterPack_stroke2_corner0_rounded.svg` | Bluesky Social PBC — trademarks | [`ASSETS.md`](../../ASSETS.md#3-bluesky-trademarks-and-brand-assets) |
- `apple_logo.svg`, `android_logo.svg` — Apple and Google trademarks | `custom_logo_japan.svg` | A Bluesky Japan logo contest entrant | [`ASSETS.md`](../../ASSETS.md#4-community-and-contest-artwork--credited-but-not-ours-to-license) |
| `apple_logo.svg` | Apple Inc. | [`ASSETS.md`](../../ASSETS.md#5-third-party-trademarks) |
| `android_logo.svg` | Google LLC | [`ASSETS.md`](../../ASSETS.md#5-third-party-trademarks) |
Each of those is described in [`ASSETS.md`](../../ASSETS.md). Adding an icon here? If it came from Central, this notice covers it. If it came from anywhere else, add it to [`ASSETS.md`](../../ASSETS.md) so the notice does not go stale.
Adding an icon here? If it came from Central, this notice already covers it. If it came from
anywhere else, add it to `ASSETS.md` so the notice does not go stale.
+12 -11
View File
@@ -1,14 +1,15 @@
The icons in this directory are the marks of third-party The icons in this directory are the marks of third-party services that appear in the Bluesky app.
services that appear in the Bluesky app — [Leaflet](https://leaflet.pub),
[Offprint](https://offprint.net), [pckt](https://pckt.blog), [Standard.site](https://standard.site), and
[Germ Network](https://germnetwork.com).
**These marks belong to their respective owners.** They are not Bluesky trademarks, they are not | Icon | Rights holder |
covered by the [MIT license](../../../LICENSE) that applies to the rest of this repository, and we |---|---|
are not in a position to grant or withhold permission to use them. We include them to identify | `leaflet.svg` | [Leaflet](https://leaflet.pub) |
those services in our UI. | `offprint.svg` | [Offprint](https://offprint.net) |
| `pckt.svg`, `pckt-full.svg` | [pckt](https://pckt.blog) |
| `standard-site.svg` | [Standard.site](https://standard.site) |
| `germ_logo.webp` | [Germ Network](https://germnetwork.com) |
If you fork this repository and keep these icons, your use of them rests on your own **These marks belong to their respective owners.** They are not Bluesky trademarks, they are not covered by the [MIT license](../../../LICENSE) that applies to the rest of this repository, and we are not in a position to grant or withhold permission to use them. We include them to identify those services in our UI.
nominative-use basis or on permission from the mark owner.
See [`ASSETS.md`](../../../ASSETS.md) for the full asset licensing picture. If you fork this repository and keep these icons, your use of them rests on your own nominative-use basis or on permission from the mark owner.
See [`ASSETS.md`](../../../ASSETS.md#5-third-party-trademarks) for the full asset licensing picture.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 @catamphetamine <purecatamphetamine@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6 -1
View File
@@ -1,4 +1,9 @@
**Rights holder: @catamphetamine.**
The flags in this directory are from the excellent [country-flag-icons](https://gitlab.com/catamphetamine/country-flag-icons). The flags in this directory are from the excellent [country-flag-icons](https://gitlab.com/catamphetamine/country-flag-icons).
MIT License
Copyright (c) 2020 @catamphetamine <purecatamphetamine@gmail.com> Copyright (c) 2020 @catamphetamine <purecatamphetamine@gmail.com>
**You may redistribute these files under country-flag-icons' MIT license, which is separate from Bluesky's repository license.** The complete license is in [`LICENSE`](./LICENSE) and must travel with redistributed copies.
See [`ASSETS.md`](../../../ASSETS.md#6-third-party-assets-you-may-redistribute) for the full asset licensing picture.
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V5h14v14H5Zm10.725-5.2c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Zm-3.367.872c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm-3.879.078C6.92 14.75 6 13.827 6 12.287v-.617c0-1.47.955-2.42 2.472-2.42.589 0 1.139.147 1.548.388.404.236.664.562.664.915 0 .373-.271.636-.656.636a.8.8 0 0 1-.41-.108 2 2 0 0 1-.271-.177c-.208-.148-.421-.3-.746-.3-.644 0-.95.38-.95 1.155v.52c0 .768.306 1.168.903 1.168.436 0 .735-.248.735-.61v-.061h-.146c-.412 0-.632-.194-.632-.551 0-.353.216-.535.632-.535h.806c.617 0 .884.256.884.834v.166c0 1.253-.92 2.06-2.354 2.06Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 992 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6 12.287c0 1.54.92 2.463 2.48 2.463 1.434 0 2.353-.807 2.353-2.06v-.166c0-.578-.267-.834-.884-.834h-.806c-.416 0-.632.182-.632.535 0 .357.22.55.632.55h.146v.063c0 .36-.299.609-.735.609-.597 0-.904-.4-.904-1.168v-.52c0-.775.307-1.155.951-1.155.464 0 .7.31 1.018.477a.8.8 0 0 0 .409.108c.385 0 .656-.263.656-.636 0-.353-.26-.679-.664-.915-.409-.24-.96-.388-1.548-.388C6.955 9.25 6 10.2 6 11.67v.617Zm6.358 2.385c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm2.565 0c.519 0 .802-.306.802-.872v-.993h1.47c.361 0 .608-.252.608-.613 0-.365-.243-.613-.608-.613h-1.47v-.973h1.666c.35 0 .609-.26.609-.629 0-.368-.26-.64-.609-.64h-2.495c-.507 0-.821.314-.821.826V13.8c0 .554.31.872.848.872ZM19 7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Zm2 10a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v10Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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

Before

Width:  |  Height:  |  Size: 514 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M19 15.355c-1.112.696-2.385 1.1-4 1.1-2.81 0-4.796-1.756-6.312-3.39a1 1 0 0 0-1.273-.16L5 14.71V17a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1.646ZM16 9a1 1 0 1 0-2 0 1 1 0 0 0 2 0Zm3-2a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v5.213l1.246-.932.044-.031a3 3 0 0 1 3.862.455c1.468 1.581 2.942 2.75 4.848 2.75 1.704 0 2.854-.558 4-1.621V7Zm-1 2a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v10Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 563 B

+6 -14
View File
@@ -1,19 +1,11 @@
The landing screen illustration in this directory was commissioned by Bluesky Social PBC from **Rights holder: [Owen D. Pomery](https://owenpomery.com/work)**, represented by [Brilliant Artists Ltd](https://brilliantartists.co.uk/).
illustrator [Owen D. Pomery](https://owenpomery.com/work), through his agent
[Brilliant Artists Ltd](https://brilliantartists.co.uk/). Copyright in the artwork remains with
the artist.
**This artwork is not covered by the [MIT license](../../LICENSE) that applies to the rest of The landing screen illustration in this directory was commissioned by Bluesky Social PBC. Copyright in the artwork remains with the artist.
this repository.** Our license is limited to Bluesky's own products and channels, it is exclusive
to us, and it does not permit us to sublicense the artwork or to distribute modified versions
of it.
If you are forking this repository, replace these files. Because our license is exclusive, the **This artwork is not covered by the [MIT license](../../LICENSE) that applies to the rest of this repository.** Our license is limited to Bluesky's own products and channels, is exclusive to us, and does not permit us to sublicense the artwork or to distribute modified versions of it.
artwork is not available for separate third-party licensing while that license runs — please do
not approach the artist or his agent for permission.
Adding a file here? It is carved out of the MIT license by directory, so a new file inherits If you are forking this repository, replace these files. Because our license is exclusive, the artwork is not available for separate third-party licensing while that license runs. Please do not approach the artist or his agent for permission — the constraint is our agreement, not their willingness. If you have already shipped it, contact us and we will help you sort it out.
that treatment automatically. If the file is not commissioned artwork covered by this notice,
put it somewhere else. Adding a file here? This directory is carved out of the MIT license, so a new file inherits that treatment. If the file is not commissioned artwork covered by this notice, put it somewhere else.
See [`ASSETS.md`](../../ASSETS.md) for the full asset licensing picture. See [`ASSETS.md`](../../ASSETS.md) for the full asset licensing picture.
+8 -9
View File
@@ -1,14 +1,13 @@
The product imagery in this directory — onboarding illustrations, **Rights holder: mixed, and we have not finished documenting it.**
chat backgrounds, feature announcement graphics, and similar — is **not covered by the
[MIT license](../../LICENSE)** that applies to the rest of this repository.
Some of it is commissioned artwork that we are not able to sublicense. Rather than have you work This directory holds product imagery — onboarding art, chat backgrounds, feature announcement graphics, and similar. Some of it is Bluesky's own work. Some was commissioned from outside illustrators, on terms that do not let us pass rights on. We are working out which is which.
out file by file which is which, treat the whole directory as not licensed for your use.
Until we have, **treat the whole directory as outside the [MIT license](../../LICENSE) and not licensed for your use.**
When this is resolved, one of two things will happen: this notice will name the rights holder for each file, or the directory will be split so that the boundary itself carries the answer. If you need a specific file's status before then, ask us and we will find out.
If you are forking this repository, replace these files or ship without them. If you are forking this repository, replace these files or ship without them.
**Internal note:** this directory is carved out of the MIT license as a whole. If you are adding Adding a file here? This directory sits outside the MIT license, so a new file inherits that treatment. If you are adding something we do want forks to be able to reuse, put it in a different directory rather than creating an exception here.
an asset that we do want forks to be able to reuse, put it somewhere else rather than adding an
exception here — the carve-out only stays maintainable if the directory boundary means something.
See [`ASSETS.md`](../../ASSETS.md) for the full asset licensing picture. See [`ASSETS.md`](../../ASSETS.md#7-product-imagery--provenance-being-documented) for the full asset licensing picture.
+7 -1
View File
@@ -35,7 +35,13 @@ module.exports = function (api) {
// cannot use `env` field because it will put them after // cannot use `env` field because it will put them after
// the `react-native-worklets/plugin` plugin // the `react-native-worklets/plugin` plugin
...(api.env('test') ...(api.env('test')
? ['@babel/plugin-transform-class-static-block'] ? [
'@babel/plugin-transform-class-static-block',
// Compile `import()` to require so jest (which runs without
// `--experimental-vm-modules`) can execute lazily-loaded modules
// like `@ipld/dag-cbor` via its moduleNameMapper.
'@babel/plugin-transform-dynamic-import',
]
: []), : []),
...(api.env('production') ? ['transform-remove-console'] : []), ...(api.env('production') ? ['transform-remove-console'] : []),
+2
View File
@@ -14,11 +14,13 @@
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.11", "@atproto/api": "0.20.11",
"@atproto/lexicon": "0.7.1",
"preact": "^10.4.8" "preact": "^10.4.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@preact/preset-vite": "^2.10.2", "@preact/preset-vite": "^2.10.2",
"@types/node": "^24.12.2",
"@vitejs/plugin-legacy": "^8.0.1", "@vitejs/plugin-legacy": "^8.0.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^9.18.0", "eslint": "^9.18.0",
+33 -14
View File
@@ -11,6 +11,9 @@ importers:
'@atproto/api': '@atproto/api':
specifier: 0.20.11 specifier: 0.20.11
version: 0.20.11 version: 0.20.11
'@atproto/lexicon':
specifier: 0.7.1
version: 0.7.1
preact: preact:
specifier: ^10.4.8 specifier: ^10.4.8
version: 10.29.1 version: 10.29.1
@@ -20,10 +23,13 @@ importers:
version: 9.39.4 version: 9.39.4
'@preact/preset-vite': '@preact/preset-vite':
specifier: ^2.10.2 specifier: ^2.10.2
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(vite@8.0.16(jiti@1.21.7)(terser@5.47.1)) version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(vite@8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1))
'@types/node':
specifier: ^24.12.2
version: 24.13.3
'@vitejs/plugin-legacy': '@vitejs/plugin-legacy':
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.0.1(terser@5.47.1)(vite@8.0.16(jiti@1.21.7)(terser@5.47.1)) version: 8.0.1(terser@5.47.1)(vite@8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1))
autoprefixer: autoprefixer:
specifier: ^10.4.19 specifier: ^10.4.19
version: 10.5.0(postcss@8.5.14) version: 10.5.0(postcss@8.5.14)
@@ -62,7 +68,7 @@ importers:
version: 8.59.3(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3) version: 8.59.3(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3)
vite: vite:
specifier: ^8.0.16 specifier: ^8.0.16
version: 8.0.16(jiti@1.21.7)(terser@5.47.1) version: 8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1)
vite-bundle-analyzer: vite-bundle-analyzer:
specifier: ^1.3.8 specifier: ^1.3.8
version: 1.3.8 version: 1.3.8
@@ -940,6 +946,9 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@typescript-eslint/eslint-plugin@8.59.3': '@typescript-eslint/eslint-plugin@8.59.3':
resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2279,6 +2288,9 @@ packages:
uint8arrays@5.1.1: uint8arrays@5.1.1:
resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==} resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
unicode-canonical-property-names-ecmascript@2.0.1: unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3360,19 +3372,19 @@ snapshots:
'@package-json/types@0.0.12': {} '@package-json/types@0.0.12': {}
'@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(vite@8.0.16(jiti@1.21.7)(terser@5.47.1))': '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(vite@8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0)
'@prefresh/vite': 2.4.12(preact@10.29.1)(vite@8.0.16(jiti@1.21.7)(terser@5.47.1)) '@prefresh/vite': 2.4.12(preact@10.29.1)(vite@8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1))
'@rollup/pluginutils': 5.3.0 '@rollup/pluginutils': 5.3.0
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0) babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0)
debug: 4.4.3 debug: 4.4.3
magic-string: 0.30.21 magic-string: 0.30.21
picocolors: 1.1.1 picocolors: 1.1.1
vite: 8.0.16(jiti@1.21.7)(terser@5.47.1) vite: 8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1)
vite-prerender-plugin: 0.5.13(vite@8.0.16(jiti@1.21.7)(terser@5.47.1)) vite-prerender-plugin: 0.5.13(vite@8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1))
zimmerframe: 1.1.4 zimmerframe: 1.1.4
transitivePeerDependencies: transitivePeerDependencies:
- preact - preact
@@ -3387,7 +3399,7 @@ snapshots:
'@prefresh/utils@1.2.1': {} '@prefresh/utils@1.2.1': {}
'@prefresh/vite@2.4.12(preact@10.29.1)(vite@8.0.16(jiti@1.21.7)(terser@5.47.1))': '@prefresh/vite@2.4.12(preact@10.29.1)(vite@8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@prefresh/babel-plugin': 0.5.3 '@prefresh/babel-plugin': 0.5.3
@@ -3395,7 +3407,7 @@ snapshots:
'@prefresh/utils': 1.2.1 '@prefresh/utils': 1.2.1
'@rollup/pluginutils': 4.2.1 '@rollup/pluginutils': 4.2.1
preact: 10.29.1 preact: 10.29.1
vite: 8.0.16(jiti@1.21.7)(terser@5.47.1) vite: 8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3470,6 +3482,10 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@24.13.3':
dependencies:
undici-types: 7.18.2
'@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3)': '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -3620,7 +3636,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1': '@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true optional: true
'@vitejs/plugin-legacy@8.0.1(terser@5.47.1)(vite@8.0.16(jiti@1.21.7)(terser@5.47.1))': '@vitejs/plugin-legacy@8.0.1(terser@5.47.1)(vite@8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0)
@@ -3635,7 +3651,7 @@ snapshots:
regenerator-runtime: 0.14.1 regenerator-runtime: 0.14.1
systemjs: 6.15.1 systemjs: 6.15.1
terser: 5.47.1 terser: 5.47.1
vite: 8.0.16(jiti@1.21.7)(terser@5.47.1) vite: 8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -4721,6 +4737,8 @@ snapshots:
dependencies: dependencies:
multiformats: 13.4.2 multiformats: 13.4.2
undici-types@7.18.2: {}
unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-match-property-ecmascript@2.0.0: unicode-match-property-ecmascript@2.0.0:
@@ -4779,7 +4797,7 @@ snapshots:
vite-bundle-analyzer@1.3.8: {} vite-bundle-analyzer@1.3.8: {}
vite-prerender-plugin@0.5.13(vite@8.0.16(jiti@1.21.7)(terser@5.47.1)): vite-prerender-plugin@0.5.13(vite@8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1)):
dependencies: dependencies:
kolorist: 1.8.0 kolorist: 1.8.0
magic-string: 0.30.21 magic-string: 0.30.21
@@ -4787,9 +4805,9 @@ snapshots:
simple-code-frame: 1.3.0 simple-code-frame: 1.3.0
source-map: 0.7.6 source-map: 0.7.6
stack-trace: 1.0.0 stack-trace: 1.0.0
vite: 8.0.16(jiti@1.21.7)(terser@5.47.1) vite: 8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1)
vite@8.0.16(jiti@1.21.7)(terser@5.47.1): vite@8.0.16(@types/node@24.13.3)(jiti@1.21.7)(terser@5.47.1):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
@@ -4797,6 +4815,7 @@ snapshots:
rolldown: 1.0.3 rolldown: 1.0.3
tinyglobby: 0.2.17 tinyglobby: 0.2.17
optionalDependencies: optionalDependencies:
'@types/node': 24.13.3
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 1.21.7 jiti: 1.21.7
terser: 5.47.1 terser: 5.47.1
+1
View File
@@ -466,6 +466,7 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
preload="metadata" preload="metadata"
// @ts-expect-error https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video#loading // @ts-expect-error https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video#loading
loading="lazy" loading="lazy"
crossorigin="anonymous"
aria-label={content.alt || undefined} aria-label={content.alt || undefined}
onClickCapture={evt => evt.stopPropagation()} onClickCapture={evt => evt.stopPropagation()}
className="w-full rounded-xl bg-black" className="w-full rounded-xl bg-black"
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2015", "target": "ES2015",
"lib": ["DOM", "ESNext"], "lib": ["DOM", "ESNext"],
"types": ["vite/client"], "types": ["node", "vite/client"],
"allowJs": false, "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
+1
View File
@@ -23,6 +23,7 @@
"lru-cache": "^11.1.0", "lru-cache": "^11.1.0",
"pg": "^8.12.0", "pg": "^8.12.0",
"pino": "^9.2.0", "pino": "^9.2.0",
"prom-client": "^15.1.3",
"uint8arrays": "^5.1.0" "uint8arrays": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
+34 -7
View File
@@ -1,4 +1,11 @@
import {Database, envToCfg, httpLogger, LinkService, readEnv} from './index.js' import {
Database,
envToCfg,
FORCE_SHUTDOWN_TIMEOUT_MS,
httpLogger,
LinkService,
readEnv,
} from './index.js'
async function main() { async function main() {
try { try {
@@ -10,6 +17,7 @@ async function main() {
httpLogger.info( httpLogger.info(
{ {
port: cfg.service.port, port: cfg.service.port,
metricsPort: cfg.service.metricsPort,
safelinkEnabled: cfg.service.safelinkEnabled, safelinkEnabled: cfg.service.safelinkEnabled,
hasDbUrl: !!cfg.db.url, hasDbUrl: !!cfg.db.url,
hasDbMigrationUrl: !!cfg.db.migrationUrl, hasDbMigrationUrl: !!cfg.db.migrationUrl,
@@ -33,17 +41,36 @@ async function main() {
if (link.ctx.cfg.service.safelinkEnabled) { if (link.ctx.cfg.service.safelinkEnabled) {
httpLogger.info('Starting Safelink client') httpLogger.info('Starting Safelink client')
link.ctx.safelinkClient.runFetchEvents() void link.ctx.safelinkClient.runFetchEvents()
} }
await link.start() await link.start()
httpLogger.info('Link service is running') httpLogger.info('Link service is running')
process.on('SIGTERM', async () => { const shutdown = (signal: NodeJS.Signals) => {
httpLogger.info('Link service is stopping') const forceExitTimer = setTimeout(() => {
await link.destroy() httpLogger.error(
httpLogger.info('Link service is stopped') {signal},
}) 'Link service exceeded its shutdown deadline; forcing exit',
)
process.exit(1)
}, FORCE_SHUTDOWN_TIMEOUT_MS)
forceExitTimer.unref()
void (async () => {
httpLogger.info({signal}, 'Link service is stopping')
try {
await link.destroy()
httpLogger.info({signal}, 'Link service is stopped')
} catch (err) {
process.exitCode = 1
httpLogger.error({err, signal}, 'Failed to stop link service cleanly')
}
})()
}
process.once('SIGTERM', shutdown)
process.once('SIGINT', shutdown)
} catch (error) { } catch (error) {
httpLogger.error( httpLogger.error(
{ {
+120 -32
View File
@@ -26,6 +26,9 @@ export class SafelinkClient {
private ozoneAgent: OzoneAgent private ozoneAgent: OzoneAgent
private cursor?: string private cursor?: string
private fetchEventsPromise?: Promise<void>
private fetchEventsTimeout?: NodeJS.Timeout
private stopped = false
constructor({cfg, db}: {cfg: ServiceConfig; db: Database}) { constructor({cfg, db}: {cfg: ServiceConfig; db: Database}) {
this.domainCache = new LRUCache<string, SafelinkRule | 'ok'>({ this.domainCache = new LRUCache<string, SafelinkRule | 'ok'>({
@@ -98,13 +101,15 @@ export class SafelinkClient {
url: string, url: string,
pattern: ToolsOzoneSafelinkDefs.PatternType, pattern: ToolsOzoneSafelinkDefs.PatternType,
): Promise<SafelinkRule> { ): Promise<SafelinkRule> {
return db.db return db.observeQuery(`resolve_safelink_${pattern}_rule`, () =>
.selectFrom('safelink_rule') db.db
.selectAll() .selectFrom('safelink_rule')
.where('url', '=', url) .selectAll()
.where('pattern', '=', pattern) .where('url', '=', url)
.orderBy('createdAt', 'desc') .where('pattern', '=', pattern)
.executeTakeFirstOrThrow() .orderBy('createdAt', 'desc')
.executeTakeFirstOrThrow(),
)
} }
private async addRule(db: Database, rule: SafelinkRule) { private async addRule(db: Database, rule: SafelinkRule) {
@@ -122,7 +127,7 @@ export class SafelinkClient {
return return
} }
db.db await db.db
.insertInto('safelink_rule') .insertInto('safelink_rule')
.values({ .values({
id: rule.id, id: rule.id,
@@ -132,12 +137,14 @@ export class SafelinkClient {
action: rule.action, action: rule.action,
createdAt: rule.createdAt, createdAt: rule.createdAt,
}) })
.onConflict(oc => oc.column('id').doNothing())
.execute() .execute()
.catch(err => { .catch(err => {
redirectLogger.error( redirectLogger.error(
{error: err, rule}, {error: err, rule},
'failed to add rule to database', 'failed to add rule to database',
) )
throw err
}) })
if (rule.pattern === 'domain') { if (rule.pattern === 'domain') {
@@ -172,6 +179,7 @@ export class SafelinkClient {
{error: err, rule}, {error: err, rule},
'failed to remove rule from database', 'failed to remove rule from database',
) )
throw err
}) })
if (rule.pattern === 'domain') { if (rule.pattern === 'domain') {
@@ -181,13 +189,71 @@ export class SafelinkClient {
} }
} }
public async runFetchEvents() { public runFetchEvents(): Promise<void> {
if (this.stopped) {
return Promise.resolve()
}
this.fetchEventsPromise ??= this.fetchEvents().finally(() => {
this.fetchEventsPromise = undefined
})
return this.fetchEventsPromise
}
public async stop(timeoutMs: number): Promise<void> {
this.stopped = true
if (this.fetchEventsTimeout) {
clearTimeout(this.fetchEventsTimeout)
this.fetchEventsTimeout = undefined
}
const activePoll = this.fetchEventsPromise
if (!activePoll) {
return
}
let timeout: NodeJS.Timeout | undefined
const stopWaiting = new Promise<void>(resolve => {
timeout = setTimeout(() => {
redirectLogger.warn(
{timeoutMs},
'Safelink poll exceeded its shutdown deadline',
)
resolve()
}, timeoutMs)
})
try {
await Promise.race([activePoll, stopWaiting])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
private scheduleFetchEvents(delay: number) {
if (this.stopped) {
return
}
this.fetchEventsTimeout = setTimeout(() => {
this.fetchEventsTimeout = undefined
void this.runFetchEvents()
}, delay)
}
private async fetchEvents() {
let agent: AtpAgent let agent: AtpAgent
try { try {
agent = await this.ozoneAgent.getAgent() agent = await this.ozoneAgent.getAgent()
} catch (err) { } catch (err) {
if (this.stopped) {
return
}
redirectLogger.error({error: err}, 'error getting Ozone agent') redirectLogger.error({error: err}, 'error getting Ozone agent')
setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL) this.scheduleFetchEvents(SAFELINK_MAX_FETCH_INTERVAL)
return
}
if (this.stopped) {
return return
} }
@@ -200,10 +266,13 @@ export class SafelinkClient {
sortDirection: 'asc', sortDirection: 'asc',
}) })
} catch (err) { } catch (err) {
if (this.stopped) {
return
}
if (err instanceof ExpiredTokenError) { if (err instanceof ExpiredTokenError) {
redirectLogger.info('ozone agent had expired session, refreshing...') redirectLogger.info('ozone agent had expired session, refreshing...')
await this.ozoneAgent.refreshSession() await this.ozoneAgent.refreshSession()
setTimeout(() => this.runFetchEvents(), SAFELINK_MIN_FETCH_INTERVAL) this.scheduleFetchEvents(SAFELINK_MIN_FETCH_INTERVAL)
return return
} }
@@ -211,37 +280,56 @@ export class SafelinkClient {
{error: err}, {error: err},
'error fetching safelink events from Ozone', 'error fetching safelink events from Ozone',
) )
setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL) this.scheduleFetchEvents(SAFELINK_MAX_FETCH_INTERVAL)
return
}
if (this.stopped) {
return return
} }
if (res.data.events.length === 0) { if (res.data.events.length === 0) {
redirectLogger.info('received no new safelink events from ozone') redirectLogger.info('received no new safelink events from ozone')
setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL) this.scheduleFetchEvents(SAFELINK_MAX_FETCH_INTERVAL)
} else { } else {
await this.db.transaction(async db => { try {
for (const rule of res.data.events) { await this.db.transaction(async db => {
switch (rule.eventType) { for (const rule of res.data.events) {
case 'removeRule': switch (rule.eventType) {
await this.removeRule(db, rule) case 'removeRule':
break await this.removeRule(db, rule)
case 'addRule': break
case 'updateRule': case 'addRule':
await this.addRule(db, rule) case 'updateRule':
break await this.addRule(db, rule)
default: break
redirectLogger.warn({rule}, 'received unknown rule event type') default:
redirectLogger.warn({rule}, 'received unknown rule event type')
}
} }
})
if (this.stopped) {
return
} }
}) if (res.data.cursor) {
if (res.data.cursor) { redirectLogger.info(
redirectLogger.info( {cursor: res.data.cursor},
{cursor: res.data.cursor}, 'received new safelink events from Ozone',
'received new safelink events from Ozone', )
await this.setCursor(res.data.cursor)
}
} catch (err) {
if (this.stopped) {
return
}
redirectLogger.error(
{error: err},
'error applying safelink events from Ozone',
) )
await this.setCursor(res.data.cursor) this.scheduleFetchEvents(SAFELINK_MAX_FETCH_INTERVAL)
return
} }
setTimeout(() => this.runFetchEvents(), SAFELINK_MIN_FETCH_INTERVAL) this.scheduleFetchEvents(SAFELINK_MIN_FETCH_INTERVAL)
} }
} }
+4
View File
@@ -7,6 +7,7 @@ export type Config = {
export type ServiceConfig = { export type ServiceConfig = {
port: number port: number
metricsPort: number
version?: string version?: string
hostnames: string[] hostnames: string[]
hostnamesSet: Set<string> hostnamesSet: Set<string>
@@ -33,6 +34,7 @@ export type DbPoolConfig = {
export type Environment = { export type Environment = {
port?: number port?: number
metricsPort?: number
version?: string version?: string
hostnames: string[] hostnames: string[]
appHostname?: string appHostname?: string
@@ -52,6 +54,7 @@ export type Environment = {
export const readEnv = (): Environment => { export const readEnv = (): Environment => {
return { return {
port: envInt('LINK_PORT'), port: envInt('LINK_PORT'),
metricsPort: envInt('LINK_METRICS_PORT'),
version: envStr('LINK_VERSION'), version: envStr('LINK_VERSION'),
hostnames: envList('LINK_HOSTNAMES'), hostnames: envList('LINK_HOSTNAMES'),
appHostname: envStr('LINK_APP_HOSTNAME'), appHostname: envStr('LINK_APP_HOSTNAME'),
@@ -74,6 +77,7 @@ export const readEnv = (): Environment => {
export const envToCfg = (env: Environment): Config => { export const envToCfg = (env: Environment): Config => {
const serviceCfg: ServiceConfig = { const serviceCfg: ServiceConfig = {
port: env.port ?? 3000, port: env.port ?? 3000,
metricsPort: env.metricsPort ?? 9090,
version: env.version, version: env.version,
hostnames: env.hostnames, hostnames: env.hostnames,
hostnamesSet: new Set(env.hostnames), hostnamesSet: new Set(env.hostnames),
+48
View File
@@ -1,4 +1,5 @@
import assert from 'assert' import assert from 'assert'
import {performance} from 'node:perf_hooks'
import { import {
Kysely, Kysely,
type KyselyPlugin, type KyselyPlugin,
@@ -17,6 +18,8 @@ import {default as migrations} from './migrations/index.js'
import {DbMigrationProvider} from './migrations/provider.js' import {DbMigrationProvider} from './migrations/provider.js'
import {type DbSchema} from './schema.js' import {type DbSchema} from './schema.js'
const SLOW_QUERY_THRESHOLD_MS = 1000
export class Database { export class Database {
migrator: Migrator migrator: Migrator
destroyed = false destroyed = false
@@ -101,6 +104,51 @@ export class Database {
return this.db.isTransaction return this.db.isTransaction
} }
async observeQuery<T>(
operation: string,
query: () => Promise<T>,
): Promise<T> {
const poolIdleConnectionsAtStart = this.cfg.pool.idleCount
const poolTotalConnectionsAtStart = this.cfg.pool.totalCount
const poolWaitingRequestsAtStart = this.cfg.pool.waitingCount
const startedAt = performance.now()
let poolIdleConnectionsAtThreshold: number | undefined
let poolTotalConnectionsAtThreshold: number | undefined
let poolWaitingRequestsAtThreshold: number | undefined
const slowQueryTimer = setTimeout(() => {
poolIdleConnectionsAtThreshold = this.cfg.pool.idleCount
poolTotalConnectionsAtThreshold = this.cfg.pool.totalCount
poolWaitingRequestsAtThreshold = this.cfg.pool.waitingCount
}, SLOW_QUERY_THRESHOLD_MS)
slowQueryTimer.unref()
try {
return await query()
} finally {
clearTimeout(slowQueryTimer)
const durationMs = Math.round(performance.now() - startedAt)
if (durationMs >= SLOW_QUERY_THRESHOLD_MS) {
log.warn(
{
durationMs,
operation,
poolIdleConnectionsAtEnd: this.cfg.pool.idleCount,
poolIdleConnectionsAtStart,
poolIdleConnectionsAtThreshold,
poolStateAtThresholdCaptured:
poolWaitingRequestsAtThreshold !== undefined,
poolTotalConnectionsAtEnd: this.cfg.pool.totalCount,
poolTotalConnectionsAtStart,
poolTotalConnectionsAtThreshold,
poolWaitingRequestsAtEnd: this.cfg.pool.waitingCount,
poolWaitingRequestsAtStart,
poolWaitingRequestsAtThreshold,
},
'slow database query',
)
}
}
}
assertTransaction() { assertTransaction() {
assert(this.isTransaction, 'Transaction required') assert(this.isTransaction, 'Transaction required')
} }
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert'
import {type AddressInfo} from 'node:net'
import {test} from 'node:test'
import {envToCfg} from './config.js'
import {LinkService} from './index.js'
const testConfig = () =>
envToCfg({
dbPostgresUrl: 'postgres://localhost:1/blink',
hostnames: ['go.bsky.app'],
metricsPort: 0,
port: 0,
safelinkAgentIdentifier: 'test',
safelinkAgentPass: 'test',
safelinkPdsUrl: 'https://example.com',
})
void test('serves and terminates the Prometheus listener', async () => {
const service = await LinkService.create(testConfig())
try {
await service.start()
const {port} = service.metricsServer?.address() as AddressInfo
const res = await fetch(`http://127.0.0.1:${port}/metrics`)
assert.strictEqual(res.status, 200)
assert.match(res.headers.get('content-type') ?? '', /text\/plain/)
const metrics = await res.text()
assert.match(metrics, /process_cpu_user_seconds_total/)
assert.match(metrics, /nodejs_eventloop_lag_max_seconds/)
assert.match(metrics, /bskylink_db_pool_connections\{state="idle"\} 0/)
assert.match(metrics, /bskylink_db_pool_connections\{state="in_use"\} 0/)
assert.match(metrics, /bskylink_db_pool_max_connections 10/)
assert.match(metrics, /bskylink_db_pool_waiting_requests 0/)
assert.doesNotMatch(metrics, /http_request_duration_seconds/)
} finally {
await service.destroy()
}
assert.strictEqual(service.metricsServer?.listening, false)
})
void test('isolates the Prometheus registry per service', async () => {
const first = await LinkService.create(testConfig())
const second = await LinkService.create(testConfig())
await Promise.all([first.destroy(), second.destroy()])
})
+47 -6
View File
@@ -4,24 +4,34 @@ import type http from 'node:http'
import cors from 'cors' import cors from 'cors'
import express from 'express' import express from 'express'
import {createHttpTerminator, type HttpTerminator} from 'http-terminator' import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
import {type Registry} from 'prom-client'
import {type Config} from './config.js' import {type Config} from './config.js'
import {AppContext} from './context.js' import {AppContext} from './context.js'
import i18n from './i18n.js' import i18n from './i18n.js'
import {createPrometheusRegistry} from './prometheus.js'
import {default as routes, errorHandler} from './routes/index.js' import {default as routes, errorHandler} from './routes/index.js'
import {REQUEST_DRAIN_TIMEOUT_MS} from './shutdown.js'
export * from './config.js' export * from './config.js'
export * from './db/index.js' export * from './db/index.js'
export * from './logger.js' export * from './logger.js'
export * from './shutdown.js'
export class LinkService { export class LinkService {
public server?: http.Server public server?: http.Server
public metricsServer?: http.Server
private terminator?: HttpTerminator private terminator?: HttpTerminator
private metricsTerminator?: HttpTerminator
private metricsRegistry: Registry
private destroyPromise?: Promise<void>
constructor( constructor(
public app: express.Application, public app: express.Application,
public ctx: AppContext, public ctx: AppContext,
) {} ) {
this.metricsRegistry = createPrometheusRegistry(ctx)
}
static async create(cfg: Config): Promise<LinkService> { static async create(cfg: Config): Promise<LinkService> {
let app = express() let app = express()
@@ -39,14 +49,45 @@ export class LinkService {
this.ctx.metrics.start() this.ctx.metrics.start()
this.server = this.app.listen(this.ctx.cfg.service.port) this.server = this.app.listen(this.ctx.cfg.service.port)
this.server.keepAliveTimeout = 90000 this.server.keepAliveTimeout = 90000
this.terminator = createHttpTerminator({server: this.server}) this.terminator = createHttpTerminator({
server: this.server,
gracefulTerminationTimeout: REQUEST_DRAIN_TIMEOUT_MS,
})
await events.once(this.server, 'listening') await events.once(this.server, 'listening')
const metricsApp = express()
metricsApp.get('/metrics', (_req, res, next) => {
res.set('Content-Type', this.metricsRegistry.contentType)
this.metricsRegistry.metrics().then(metrics => res.end(metrics), next)
})
this.metricsServer = metricsApp.listen(this.ctx.cfg.service.metricsPort)
this.metricsTerminator = createHttpTerminator({
server: this.metricsServer,
gracefulTerminationTimeout: 2000,
})
await events.once(this.metricsServer, 'listening')
} }
async destroy() { destroy(): Promise<void> {
this.destroyPromise ??= this.destroyInternal()
return this.destroyPromise
}
private async destroyInternal() {
this.ctx.abortController.abort() this.ctx.abortController.abort()
await this.terminator?.terminate() try {
await this.ctx.db.close() await Promise.all([
this.ctx.metrics.stop() this.terminator?.terminate(),
this.metricsTerminator?.terminate(),
this.ctx.safelinkClient.stop(REQUEST_DRAIN_TIMEOUT_MS),
])
} finally {
try {
await this.ctx.db.close()
} finally {
this.ctx.metrics.stop()
}
}
} }
} }
+54
View File
@@ -0,0 +1,54 @@
import {collectDefaultMetrics, Gauge, Registry} from 'prom-client'
import {type AppContext} from './context.js'
let runtimeRegistry: Registry | undefined
const getRuntimeRegistry = (): Registry => {
if (!runtimeRegistry) {
runtimeRegistry = new Registry()
// Beyla already exports HTTP RED metrics and traces for Blink. These
// process metrics cover the runtime-only failure modes it cannot see,
// particularly event-loop stalls, GC pauses, and V8 heap pressure.
collectDefaultMetrics({register: runtimeRegistry})
}
return runtimeRegistry
}
export const createPrometheusRegistry = (ctx: AppContext): Registry => {
const poolRegistry = new Registry()
new Gauge<'state'>({
name: 'bskylink_db_pool_connections',
help: 'PostgreSQL client connections by usage state.',
labelNames: ['state'],
registers: [poolRegistry],
collect() {
const {idleCount, totalCount} = ctx.db.cfg.pool
this.set({state: 'idle'}, idleCount)
this.set({state: 'in_use'}, totalCount - idleCount)
},
})
new Gauge({
name: 'bskylink_db_pool_max_connections',
help: 'Configured maximum PostgreSQL client connections.',
registers: [poolRegistry],
collect() {
this.set(ctx.cfg.db.pool.size)
},
})
new Gauge({
name: 'bskylink_db_pool_waiting_requests',
help: 'Requests waiting for a PostgreSQL client connection.',
registers: [poolRegistry],
collect() {
this.set(ctx.db.cfg.pool.waitingCount)
},
})
return Registry.merge([getRuntimeRegistry(), poolRegistry])
}
+2 -2
View File
@@ -8,7 +8,7 @@ import {linkRedirectContents} from '../html/linkRedirectContents.js'
import {linkWarningContents} from '../html/linkWarningContents.js' import {linkWarningContents} from '../html/linkWarningContents.js'
import {linkWarningLayout} from '../html/linkWarningLayout.js' import {linkWarningLayout} from '../html/linkWarningLayout.js'
import {redirectLogger} from '../logger.js' import {redirectLogger} from '../logger.js'
import {handler} from './util.js' import {observedHandler} from './util.js'
const INTERNAL_IP_REGEX = new RegExp( const INTERNAL_IP_REGEX = new RegExp(
'(^127.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^10.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.1[6-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.2[0-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.3[0-1]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^192.168.[0-9]{1,3}.[0-9]{1,3}$)|^localhost', '(^127.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^10.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.1[6-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.2[0-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.3[0-1]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^192.168.[0-9]{1,3}.[0-9]{1,3}$)|^localhost',
@@ -18,7 +18,7 @@ const INTERNAL_IP_REGEX = new RegExp(
export default function (ctx: AppContext, app: Express) { export default function (ctx: AppContext, app: Express) {
return app.get( return app.get(
'/redirect', '/redirect',
handler(async (req, res) => { observedHandler('redirect', async (req, res) => {
let link = req.query.u let link = req.query.u
assert( assert(
typeof link === 'string', typeof link === 'string',
+9 -7
View File
@@ -4,23 +4,25 @@ import {DAY, SECOND} from '@atproto/common'
import {Express} from 'express' import {Express} from 'express'
import {AppContext} from '../context.js' import {AppContext} from '../context.js'
import {handler} from './util.js' import {observedHandler} from './util.js'
export default function (ctx: AppContext, app: Express) { export default function (ctx: AppContext, app: Express) {
return app.get( return app.get(
'/:linkId', '/:linkId',
handler(async (req, res) => { observedHandler('short_link', async (req, res) => {
const linkId = req.params.linkId const linkId = req.params.linkId
const contentType = req.accepts(['html', 'json']) const contentType = req.accepts(['html', 'json'])
assert( assert(
typeof linkId === 'string', typeof linkId === 'string',
'express guarantees id parameter is a string', 'express guarantees id parameter is a string',
) )
const found = await ctx.db.db const found = await ctx.db.observeQuery('resolve_short_link', () =>
.selectFrom('link') ctx.db.db
.selectAll() .selectFrom('link')
.where('id', '=', linkId) .selectAll()
.executeTakeFirst() .where('id', '=', linkId)
.executeTakeFirst(),
)
if (!found) { if (!found) {
// potentially broken or mistyped link // potentially broken or mistyped link
res.setHeader('Cache-Control', 'no-store') res.setHeader('Cache-Control', 'no-store')
+30
View File
@@ -1,7 +1,11 @@
import {performance} from 'node:perf_hooks'
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express' import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
import {httpLogger} from '../logger.js' import {httpLogger} from '../logger.js'
const SLOW_REQUEST_THRESHOLD_MS = 1000
export type Handler = (req: Request, res: Response) => Awaited<void> export type Handler = (req: Request, res: Response) => Awaited<void>
export const handler = (runHandler: Handler): RequestHandler => { export const handler = (runHandler: Handler): RequestHandler => {
@@ -14,6 +18,32 @@ export const handler = (runHandler: Handler): RequestHandler => {
} }
} }
export const observedHandler = (
operation: string,
runHandler: Handler,
): RequestHandler => {
return handler(async (req, res) => {
const startedAt = performance.now()
try {
await runHandler(req, res)
} finally {
const durationMs = Math.round(performance.now() - startedAt)
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) {
httpLogger.warn(
{
durationMs,
method: req.method,
operation,
requestTraceId: req.get('x-amzn-trace-id'),
statusCode: res.statusCode,
},
'slow request',
)
}
}
})
}
export const errorHandler: ErrorRequestHandler = (err, _req, res, next) => { export const errorHandler: ErrorRequestHandler = (err, _req, res, next) => {
httpLogger.error({err}, 'request error') httpLogger.error({err}, 'request error')
if (res.headersSent) { if (res.headersSent) {
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert'
import {describe, it} from 'node:test'
import {SafelinkClient} from './cache/safelinkClient.js'
const createClient = (getAgent: () => Promise<unknown>) => {
const client: SafelinkClient = Object.create(SafelinkClient.prototype)
Reflect.set(client, 'stopped', false)
Reflect.set(client, 'ozoneAgent', {getAgent})
Reflect.set(client, 'domainCache', {delete: () => {}})
Reflect.set(client, 'urlCache', {delete: () => {}})
return client
}
void describe('Safelink shutdown', () => {
void it('clears a scheduled retry and cannot restart after stop', async () => {
const client = createClient(() =>
Promise.reject(new Error('Ozone unavailable')),
)
await client.runFetchEvents()
assert.ok(Reflect.get(client, 'fetchEventsTimeout'))
await client.stop(1_000)
assert.strictEqual(Reflect.get(client, 'fetchEventsTimeout'), undefined)
assert.strictEqual(Reflect.get(client, 'stopped'), true)
await client.runFetchEvents()
assert.strictEqual(Reflect.get(client, 'fetchEventsTimeout'), undefined)
})
void it('waits for an active poll to finish before stopping', async () => {
let pollStarted = () => {}
const started = new Promise<void>(resolve => {
pollStarted = () => resolve(undefined)
})
let finishPoll = () => {}
const releasePoll = new Promise<void>(resolve => {
finishPoll = () => resolve(undefined)
})
const client = createClient(async () => {
pollStarted()
await releasePoll
throw new Error('poll released during shutdown')
})
const polling = client.runFetchEvents()
await started
let stopped = false
const stopping = client.stop(1_000).then(() => {
stopped = true
})
await new Promise(resolve => setTimeout(resolve, 25))
assert.strictEqual(stopped, false)
finishPoll()
await Promise.all([polling, stopping])
assert.strictEqual(stopped, true)
})
void it(
'bounds the wait for a poll that never finishes',
{timeout: 1_000},
async () => {
const client = createClient(() => new Promise<never>(() => {}))
void client.runFetchEvents()
const startedAt = Date.now()
await client.stop(25)
assert.ok(Date.now() - startedAt >= 20)
},
)
void it('retries a failed rule write without advancing the cursor', async () => {
const client = createClient(() =>
Promise.resolve({
tools: {
ozone: {
safelink: {
queryEvents: () =>
Promise.resolve({
data: {
cursor: 'next',
events: [
{
action: 'block',
createdAt: new Date().toISOString(),
eventType: 'addRule',
id: 1,
pattern: 'domain',
url: 'example.com',
},
],
},
}),
},
},
},
}),
)
Reflect.set(client, 'cursor', 'current')
Reflect.set(client, 'db', {
transaction: (run: (db: unknown) => Promise<void>) =>
run({
db: {
insertInto: () => ({
values: () => ({
onConflict: () => ({
execute: () =>
Promise.reject(new Error('database unavailable')),
}),
}),
}),
},
}),
})
await client.runFetchEvents()
assert.ok(Reflect.get(client, 'fetchEventsTimeout'))
assert.strictEqual(Reflect.get(client, 'cursor'), 'current')
await client.stop(1_000)
})
void it('advances the cursor after replaying an existing rule event', async () => {
const client = createClient(() =>
Promise.resolve({
tools: {
ozone: {
safelink: {
queryEvents: () =>
Promise.resolve({
data: {
cursor: 'next',
events: [
{
action: 'block',
createdAt: new Date().toISOString(),
eventType: 'addRule',
id: 1,
pattern: 'domain',
url: 'example.com',
},
],
},
}),
},
},
},
}),
)
Reflect.set(client, 'cursor', 'current')
let storedCursor = 'current'
Reflect.set(client, 'db', {
transaction: (run: (db: unknown) => Promise<void>) =>
run({
db: {
insertInto: () => ({
values: () => ({
onConflict: () => ({
execute: () => Promise.resolve(),
}),
}),
}),
},
}),
db: {
insertInto: () => ({
values: ({cursor}: {cursor: string}) => ({
onConflict: () => ({
execute: () => {
storedCursor = cursor
return Promise.resolve()
},
}),
}),
}),
},
})
await client.runFetchEvents()
try {
assert.strictEqual(storedCursor, 'next')
assert.strictEqual(Reflect.get(client, 'cursor'), 'next')
} finally {
await client.stop(1_000)
}
})
})
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert'
import events from 'node:events'
import http from 'node:http'
import {describe, it} from 'node:test'
import {createHttpTerminator} from 'http-terminator'
import {REQUEST_DRAIN_TIMEOUT_MS} from './shutdown.js'
describe('HTTP shutdown', () => {
it('allows in-flight requests to finish during the drain window', async () => {
let beginRequest = () => {}
const requestStarted = new Promise<void>(resolve => {
beginRequest = () => resolve(undefined)
})
let finishRequest = () => {}
const releaseRequest = new Promise<void>(resolve => {
finishRequest = () => resolve(undefined)
})
const server = http.createServer(async (_req, res) => {
beginRequest()
await releaseRequest
res.end('finished')
})
server.listen(0, '127.0.0.1')
await events.once(server, 'listening')
const address = server.address()
assert.ok(address && typeof address !== 'string')
const responsePromise = fetch(`http://127.0.0.1:${address.port}`)
await requestStarted
const terminator = createHttpTerminator({
server,
gracefulTerminationTimeout: REQUEST_DRAIN_TIMEOUT_MS,
})
let termination: Promise<void> | undefined
try {
let terminated = false
termination = terminator.terminate().then(() => {
terminated = true
})
await new Promise(resolve => setTimeout(resolve, 25))
assert.strictEqual(terminated, false)
finishRequest()
const response = await responsePromise
assert.strictEqual(await response.text(), 'finished')
await termination
assert.strictEqual(terminated, true)
} finally {
finishRequest()
await (termination ?? terminator.terminate())
}
})
it('uses the shared 60 second request drain budget', () => {
assert.strictEqual(REQUEST_DRAIN_TIMEOUT_MS, 60_000)
})
})
+2
View File
@@ -0,0 +1,2 @@
export const REQUEST_DRAIN_TIMEOUT_MS = 60_000
export const FORCE_SHUTDOWN_TIMEOUT_MS = REQUEST_DRAIN_TIMEOUT_MS + 4_000
+25
View File
@@ -161,6 +161,11 @@
dependencies: dependencies:
make-plural "^7.0.0" make-plural "^7.0.0"
"@opentelemetry/api@^1.4.0":
version "1.9.1"
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05"
integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==
"@tsconfig/node10@^1.0.7": "@tsconfig/node10@^1.0.7":
version "1.0.11" version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
@@ -338,6 +343,11 @@ base64-js@^1.3.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
bintrees@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8"
integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==
body-parser@1.20.2, body-parser@^1.20.2: body-parser@1.20.2, body-parser@^1.20.2:
version "1.20.2" version "1.20.2"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd"
@@ -1060,6 +1070,14 @@ process@^0.11.10:
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
prom-client@^15.1.3:
version "15.1.3"
resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-15.1.3.tgz#69fa8de93a88bc9783173db5f758dc1c69fa8fc2"
integrity sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==
dependencies:
"@opentelemetry/api" "^1.4.0"
tdigest "^0.1.1"
proxy-addr@~2.0.7: proxy-addr@~2.0.7:
version "2.0.7" version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
@@ -1232,6 +1250,13 @@ string_decoder@^1.3.0:
dependencies: dependencies:
safe-buffer "~5.2.0" safe-buffer "~5.2.0"
tdigest@^0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced"
integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==
dependencies:
bintrees "1.0.2"
thread-stream@^2.6.0: thread-stream@^2.6.0:
version "2.7.0" version "2.7.0"
resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-2.7.0.tgz#d8a8e1b3fd538a6cca8ce69dbe5d3d097b601e11" resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-2.7.0.tgz#d8a8e1b3fd538a6cca8ce69dbe5d3d097b601e11"
+96
View File
@@ -0,0 +1,96 @@
(c) 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
Copyright 2015-2020 Google LLC. All Rights Reserved.
Copyright 2024 The Noto Project Authors (https://github.com/notofonts/hebrew)
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/thai)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+19 -9
View File
@@ -1,16 +1,26 @@
These are subsets of # Font licensing
[Inter](https://rsms.me/inter/) by Rasmus Andersson, used by the OG card rendering service.
Inter is licensed under the SIL Open Font License, Version 1.1. The full license text is at This directory contains Inter subsets tracked in the repository and Noto Sans fonts downloaded during the OG card build. Both are separately licensed under the SIL Open Font License, Version 1.1, rather than Bluesky's [MIT license](../../../../LICENSE).
[`assets/fonts/inter/OFL.txt`](../../../../assets/fonts/inter/OFL.txt) and must travel with these
files. ## Inter
``` ```
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
``` ```
**You may redistribute these files.** They are not carved out of our The tracked `Inter-Bold.ttf`, `Inter-Regular.ttf`, and `Inter-SemiBold.ttf` files are subsets of [Inter](https://rsms.me/inter/) by Rasmus Andersson. The full license text is at [`assets/fonts/inter/OFL.txt`](../../../../assets/fonts/inter/OFL.txt). The bundled OFL does not designate a Reserved Font Name.
[MIT license](../../../../LICENSE) — this notice exists because the OFL requires it.
**One thing to watch:** OFL 1.1 includes a Reserved Font Name provision. If you modify or subset ## Noto Sans
the font further, you may not distribute the result under the name "Inter."
[`bskyogcard/scripts/install-fonts.ts`](../../../scripts/install-fonts.ts) downloads Noto Sans Arabic, Hebrew, HK, JP, KR, SC, TC, and Thai into this directory. The build copies them into `bskyogcard/dist/assets/fonts/`, and the Docker image contains both locations.
```
(c) 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
Copyright 2015-2020 Google LLC. All Rights Reserved.
Copyright 2024 The Noto Project Authors (https://github.com/notofonts/hebrew)
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/thai)
```
Their full license text is in [`OFL-NOTO.txt`](./OFL-NOTO.txt). The CJK families reserve the name "Source."
**You may redistribute these fonts under their respective OFL notices. Keep the applicable copyright notice and OFL text with every redistributed copy.**
+1 -2
View File
@@ -7,5 +7,4 @@ export const isView = AppBskyGraphDefs.isStarterPackView
* Matches any starter pack view exported by our SDK * Matches any starter pack view exported by our SDK
*/ */
export type AnyStarterPackView = export type AnyStarterPackView =
| AppBskyGraphDefs.StarterPackViewBasic AppBskyGraphDefs.StarterPackViewBasic | AppBskyGraphDefs.StarterPackView
| AppBskyGraphDefs.StarterPackView
+1 -4
View File
@@ -554,11 +554,8 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
} }
} }
if pv.ReplyCount != nil { if pv.ReplyCount != nil && *pv.ReplyCount > 0 {
node.CommentCount = pv.ReplyCount node.CommentCount = pv.ReplyCount
} else {
zero := int64(0)
node.CommentCount = &zero
} }
if !embedHidden { if !embedHidden {
+36 -1
View File
@@ -313,7 +313,7 @@ func TestBuildPostJSONLD_Bare(t *testing.T) {
if main["datePublished"] != "2024-01-02T03:04:05Z" { if main["datePublished"] != "2024-01-02T03:04:05Z" {
t.Errorf("datePublished wrong: %v", main["datePublished"]) t.Errorf("datePublished wrong: %v", main["datePublished"])
} }
// commentCount should always be emitted, even at zero. // Positive commentCount values should be emitted.
cc, ok := main["commentCount"].(float64) cc, ok := main["commentCount"].(float64)
if !ok || int64(cc) != 3 { if !ok || int64(cc) != 3 {
t.Errorf("commentCount wrong: %v", main["commentCount"]) t.Errorf("commentCount wrong: %v", main["commentCount"])
@@ -342,6 +342,41 @@ func TestBuildPostJSONLD_Bare(t *testing.T) {
} }
} }
func TestBuildPostJSONLD_CommentCount(t *testing.T) {
tests := []struct {
name string
count *int64
want *int64
}{
{name: "nil", count: nil, want: nil},
{name: "zero", count: intPtr(0), want: nil},
{name: "negative", count: intPtr(-1), want: nil},
{name: "positive", count: intPtr(3), want: intPtr(3)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
pv.ReplyCount = tt.count
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
got, present := main["commentCount"]
if tt.want == nil {
if present {
t.Errorf("commentCount should be omitted, got %v", got)
}
return
}
if !present || int64(got.(float64)) != *tt.want {
t.Errorf("commentCount = %v, want %d", got, *tt.want)
}
})
}
}
func TestBuildPostJSONLD_WithImages(t *testing.T) { func TestBuildPostJSONLD_WithImages(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg" 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" thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
+1 -1
View File
@@ -44,7 +44,7 @@ Double check yourself by ensuring that the `version` in `package.json` matches
what's in the latest release. what's in the latest release.
This new OTA branch should follow the format `1.X.0-ota-1`. If one or more OTAs This new OTA branch should follow the format `1.X.0-ota-1`. If one or more OTAs
have already been deployed for this release, incremement the branch name e.g. have already been deployed for this release, increment the branch name e.g.
`1.x.0-ota-2`. `1.x.0-ota-2`.
### 4. Add commits to the OTA branch ### 4. Add commits to the OTA branch
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+1 -1
View File
@@ -6,7 +6,7 @@
}, },
"build": { "build": {
"base": { "base": {
"node": "24.18.0" "node": "24.19.0"
}, },
"development": { "development": {
"extends": "base", "extends": "base",
-32
View File
@@ -1,32 +0,0 @@
const BANNED_IMPORTS = [
'@fortawesome/free-regular-svg-icons',
'@fortawesome/free-solid-svg-icons',
]
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Prevent importing entire icon packages',
},
schema: [],
},
create(context) {
return {
ImportDeclaration(node) {
const source = node.source
if (typeof source.value !== 'string') {
return
}
if (BANNED_IMPORTS.includes(source.value)) {
context.report({
node,
message:
'Import the specific thing you want instead of the entire package',
})
}
},
}
},
}
+32 -13
View File
@@ -21,9 +21,10 @@ jest.mock('react-native-safe-area-context', () => {
const inset = {top: 0, right: 0, bottom: 0, left: 0} const inset = {top: 0, right: 0, bottom: 0, left: 0}
return { return {
SafeAreaProvider: jest.fn().mockImplementation(({children}) => children), SafeAreaProvider: jest.fn().mockImplementation(({children}) => children),
SafeAreaConsumer: jest SafeAreaConsumer: jest.fn().mockImplementation(
.fn() /** @param {{children: (i: typeof inset) => unknown}} props */
.mockImplementation(({children}) => children(inset)), ({children}) => children(inset),
),
useSafeAreaInsets: jest.fn().mockImplementation(() => inset), useSafeAreaInsets: jest.fn().mockImplementation(() => inset),
} }
}) })
@@ -55,6 +56,16 @@ jest.mock('expo-media-library', () => ({
__esModule: true, // this property makes it work __esModule: true, // this property makes it work
default: jest.fn(), default: jest.fn(),
usePermissions: jest.fn(() => [true]), usePermissions: jest.fn(() => [true]),
requestPermissionsAsync: jest.fn().mockResolvedValue({granted: true}),
saveToLibraryAsync: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('expo-media-library/legacy', () => ({
__esModule: true,
default: jest.fn(),
usePermissions: jest.fn(() => [true]),
requestPermissionsAsync: jest.fn().mockResolvedValue({granted: true}),
saveToLibraryAsync: jest.fn().mockResolvedValue(undefined),
})) }))
jest.mock('@bsky.app/expo-guess-language', () => ({ jest.mock('@bsky.app/expo-guess-language', () => ({
@@ -85,18 +96,26 @@ jest.mock('expo-application', () => ({
})) }))
jest.mock('expo-modules-core', () => ({ jest.mock('expo-modules-core', () => ({
requireNativeModule: jest.fn().mockImplementation(moduleName => { requireNativeModule: jest.fn().mockImplementation(
if (moduleName === 'ExpoPlatformInfo') { /** @param {string} moduleName */
return { moduleName => {
getIsReducedMotionEnabled: () => false, if (moduleName === 'ExpoPlatformInfo') {
return {
getIsReducedMotionEnabled: () => false,
}
} }
} if (moduleName === 'BottomSheet') {
if (moduleName === 'BottomSheet') { return {
return { dismissAll: () => {},
dismissAll: () => {}, }
} }
}
}), const expoModules = /** @type {Record<string, unknown> | undefined} */ (
globalThis.expo?.modules
)
return expoModules?.[moduleName]
},
),
requireNativeViewManager: jest.fn().mockImplementation(_ => { requireNativeViewManager: jest.fn().mockImplementation(_ => {
return () => null return () => null
}), }),
+1332
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
{
"lexicon": 1,
"id": "app.bsky.actor.contentVisibilityDeclaration",
"defs": {
"main": {
"type": "record",
"description": "A declaration of an account's preferences for appearing in content discovery surfaces.",
"key": "literal:self",
"record": {
"type": "object",
"required": ["hideFromAlgorithmicRecommendations"],
"properties": {
"hideFromAlgorithmicRecommendations": {
"type": "boolean",
"description": "Whether the account requests that its posts be hidden from algorithmic recommendations. Consumers must treat a missing record as false."
}
}
}
}
}
}
+994
View File
@@ -0,0 +1,994 @@
{
"id": "app.bsky.actor.defs",
"defs": {
"nux": {
"type": "object",
"required": [
"id",
"completed"
],
"properties": {
"id": {
"type": "string",
"maxLength": 100
},
"data": {
"type": "string",
"maxLength": 3000,
"description": "Arbitrary data for the NUX. The structure is defined by the NUX itself. Limited to 300 characters.",
"maxGraphemes": 300
},
"completed": {
"type": "boolean",
"default": false
},
"expiresAt": {
"type": "string",
"format": "datetime",
"description": "The date and time at which the NUX will expire and should be considered completed."
}
},
"description": "A new user experiences (NUX) storage object"
},
"mutedWord": {
"type": "object",
"required": [
"value",
"targets"
],
"properties": {
"id": {
"type": "string"
},
"value": {
"type": "string",
"maxLength": 10000,
"description": "The muted word itself.",
"maxGraphemes": 1000
},
"targets": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#mutedWordTarget",
"type": "ref"
},
"description": "The intended targets of the muted word."
},
"expiresAt": {
"type": "string",
"format": "datetime",
"description": "The date and time at which the muted word will expire and no longer be applied."
},
"actorTarget": {
"type": "string",
"default": "all",
"description": "Groups of users to apply the muted word to. If undefined, applies to all users.",
"knownValues": [
"all",
"exclude-following"
]
}
},
"description": "A word that the account owner has muted."
},
"savedFeed": {
"type": "object",
"required": [
"id",
"type",
"value",
"pinned"
],
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"knownValues": [
"feed",
"list",
"timeline"
]
},
"value": {
"type": "string"
},
"pinned": {
"type": "boolean"
}
}
},
"statusView": {
"type": "object",
"required": [
"status",
"record"
],
"properties": {
"cid": {
"type": "string",
"format": "cid"
},
"uri": {
"type": "string",
"format": "at-uri"
},
"embed": {
"refs": [
"app.bsky.embed.external#view"
],
"type": "union",
"description": "An optional embed associated with the status."
},
"labels": {
"type": "array",
"items": {
"ref": "com.atproto.label.defs#label",
"type": "ref"
}
},
"record": {
"type": "unknown"
},
"status": {
"type": "string",
"description": "The status for the account.",
"knownValues": [
"app.bsky.actor.status#live"
]
},
"isActive": {
"type": "boolean",
"description": "True if the status is not expired, false if it is expired. Only present if expiration was set."
},
"expiresAt": {
"type": "string",
"format": "datetime",
"description": "The date when this status will expire. The application might choose to no longer return the status after expiration."
},
"isDisabled": {
"type": "boolean",
"description": "True if the user's go-live access has been disabled by a moderator, false otherwise."
}
}
},
"preferences": {
"type": "array",
"items": {
"refs": [
"#adultContentPref",
"#contentLabelPref",
"#savedFeedsPref",
"#savedFeedsPrefV2",
"#personalDetailsPref",
"#declaredAgePref",
"#feedViewPref",
"#threadViewPref",
"#interestsPref",
"#mutedWordsPref",
"#hiddenPostsPref",
"#bskyAppStatePref",
"#labelersPref",
"#postInteractionSettingsPref",
"#verificationPrefs",
"#liveEventPreferences"
],
"type": "union"
}
},
"profileView": {
"type": "object",
"required": [
"did",
"handle"
],
"properties": {
"did": {
"type": "string",
"format": "did"
},
"debug": {
"type": "unknown",
"description": "Debug information for internal development"
},
"avatar": {
"type": "string",
"format": "uri"
},
"handle": {
"type": "string",
"format": "handle"
},
"labels": {
"type": "array",
"items": {
"ref": "com.atproto.label.defs#label",
"type": "ref"
}
},
"status": {
"ref": "#statusView",
"type": "ref"
},
"viewer": {
"ref": "#viewerState",
"type": "ref"
},
"pronouns": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "datetime"
},
"indexedAt": {
"type": "string",
"format": "datetime"
},
"associated": {
"ref": "#profileAssociated",
"type": "ref"
},
"description": {
"type": "string",
"maxLength": 2560,
"maxGraphemes": 256
},
"displayName": {
"type": "string",
"maxLength": 640,
"maxGraphemes": 64
},
"verification": {
"ref": "#verificationState",
"type": "ref"
}
}
},
"viewerState": {
"type": "object",
"properties": {
"muted": {
"type": "boolean",
"description": "Whether the account is fully muted, directly or via a mutelist. False when the mute is scoped to specific kinds; see mutedOnlyReposts and mutedOnlyQuoteposts."
},
"blocking": {
"type": "string",
"format": "at-uri"
},
"blockedBy": {
"type": "boolean"
},
"following": {
"type": "string",
"format": "at-uri"
},
"followedBy": {
"type": "string",
"format": "at-uri"
},
"mutedByList": {
"ref": "app.bsky.graph.defs#listViewBasic",
"type": "ref"
},
"blockingByList": {
"ref": "app.bsky.graph.defs#listViewBasic",
"type": "ref"
},
"knownFollowers": {
"ref": "#knownFollowers",
"type": "ref",
"description": "This property is present only in selected cases, as an optimization."
},
"mutedOnlyReposts": {
"type": "boolean",
"description": "Whether the account's reposts are muted. Scoped mutes are exclusive with muted: this can be true while muted is false. If muted is true, this will be false."
},
"mutedOnlyQuoteposts": {
"type": "boolean",
"description": "Whether the account's quote posts are muted. Scoped mutes are exclusive with muted: this can be true while muted is false. If muted is true, this will be false."
},
"activitySubscription": {
"ref": "app.bsky.notification.defs#activitySubscription",
"type": "ref",
"description": "This property is present only in selected cases, as an optimization."
}
},
"description": "Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests."
},
"feedViewPref": {
"type": "object",
"required": [
"feed"
],
"properties": {
"feed": {
"type": "string",
"description": "The URI of the feed, or an identifier which describes the feed."
},
"hideReplies": {
"type": "boolean",
"description": "Hide replies in the feed."
},
"hideReposts": {
"type": "boolean",
"description": "Hide reposts in the feed."
},
"hideQuotePosts": {
"type": "boolean",
"description": "Hide quote posts in the feed."
},
"hideRepliesByLikeCount": {
"type": "integer",
"description": "Hide replies in the feed if they do not have this number of likes."
},
"hideRepliesByUnfollowed": {
"type": "boolean",
"default": true,
"description": "Hide replies in the feed if they are not by followed users."
}
}
},
"labelersPref": {
"type": "object",
"required": [
"labelers"
],
"properties": {
"labelers": {
"type": "array",
"items": {
"ref": "#labelerPrefItem",
"type": "ref"
}
}
}
},
"interestsPref": {
"type": "object",
"required": [
"tags"
],
"properties": {
"tags": {
"type": "array",
"items": {
"type": "string",
"maxLength": 640,
"maxGraphemes": 64
},
"maxLength": 100,
"description": "A list of tags which describe the account owner's interests gathered during onboarding."
}
}
},
"knownFollowers": {
"type": "object",
"required": [
"count",
"followers"
],
"properties": {
"count": {
"type": "integer"
},
"followers": {
"type": "array",
"items": {
"ref": "#profileViewBasic",
"type": "ref"
},
"maxLength": 5,
"minLength": 0
}
},
"description": "The subject's followers whom you also follow"
},
"mutedWordsPref": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#mutedWord",
"type": "ref"
},
"description": "A list of words the account owner has muted."
}
}
},
"savedFeedsPref": {
"type": "object",
"required": [
"pinned",
"saved"
],
"properties": {
"saved": {
"type": "array",
"items": {
"type": "string",
"format": "at-uri"
}
},
"pinned": {
"type": "array",
"items": {
"type": "string",
"format": "at-uri"
}
},
"timelineIndex": {
"type": "integer"
}
}
},
"threadViewPref": {
"type": "object",
"properties": {
"sort": {
"type": "string",
"description": "Sorting mode for threads.",
"knownValues": [
"oldest",
"newest",
"most-likes",
"random",
"hotness"
]
}
}
},
"declaredAgePref": {
"type": "object",
"properties": {
"isOverAge13": {
"type": "boolean",
"description": "Indicates if the user has declared that they are over 13 years of age."
},
"isOverAge16": {
"type": "boolean",
"description": "Indicates if the user has declared that they are over 16 years of age."
},
"isOverAge18": {
"type": "boolean",
"description": "Indicates if the user has declared that they are over 18 years of age."
}
},
"description": "Read-only preference containing value(s) inferred from the user's declared birthdate. Absence of this preference object in the response indicates that the user has not made a declaration."
},
"hiddenPostsPref": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"items": {
"type": "string",
"format": "at-uri"
},
"description": "A list of URIs of posts the account owner has hidden."
}
}
},
"labelerPrefItem": {
"type": "object",
"required": [
"did"
],
"properties": {
"did": {
"type": "string",
"format": "did"
}
}
},
"mutedWordTarget": {
"type": "string",
"maxLength": 640,
"knownValues": [
"content",
"tag"
],
"maxGraphemes": 64
},
"adultContentPref": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean",
"default": false
}
}
},
"bskyAppStatePref": {
"type": "object",
"properties": {
"nuxs": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#nux",
"type": "ref"
},
"maxLength": 100,
"description": "Storage for NUXs the user has encountered."
},
"isBetaUser": {
"type": "boolean",
"description": "Indicates if the user is participating in the beta features program."
},
"queuedNudges": {
"type": "array",
"items": {
"type": "string",
"maxLength": 100
},
"maxLength": 1000,
"description": "An array of tokens which identify nudges (modals, popups, tours, highlight dots) that should be shown to the user."
},
"activeProgressGuide": {
"ref": "#bskyAppProgressGuide",
"type": "ref"
}
},
"description": "A grab bag of state that's specific to the bsky.app program. Third-party apps shouldn't use this."
},
"contentLabelPref": {
"type": "object",
"required": [
"label",
"visibility"
],
"properties": {
"label": {
"type": "string"
},
"labelerDid": {
"type": "string",
"format": "did",
"description": "Which labeler does this preference apply to? If undefined, applies globally."
},
"visibility": {
"type": "string",
"knownValues": [
"ignore",
"show",
"warn",
"hide"
]
}
}
},
"profileViewBasic": {
"type": "object",
"required": [
"did",
"handle"
],
"properties": {
"did": {
"type": "string",
"format": "did"
},
"debug": {
"type": "unknown",
"description": "Debug information for internal development"
},
"avatar": {
"type": "string",
"format": "uri"
},
"handle": {
"type": "string",
"format": "handle"
},
"labels": {
"type": "array",
"items": {
"ref": "com.atproto.label.defs#label",
"type": "ref"
}
},
"status": {
"ref": "#statusView",
"type": "ref"
},
"viewer": {
"ref": "#viewerState",
"type": "ref"
},
"pronouns": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "datetime"
},
"associated": {
"ref": "#profileAssociated",
"type": "ref"
},
"displayName": {
"type": "string",
"maxLength": 640,
"maxGraphemes": 64
},
"verification": {
"ref": "#verificationState",
"type": "ref"
}
}
},
"savedFeedsPrefV2": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#savedFeed",
"type": "ref"
}
}
}
},
"verificationView": {
"type": "object",
"required": [
"issuer",
"uri",
"isValid",
"createdAt"
],
"properties": {
"uri": {
"type": "string",
"format": "at-uri",
"description": "The AT-URI of the verification record."
},
"issuer": {
"type": "string",
"format": "did",
"description": "The user who issued this verification."
},
"isValid": {
"type": "boolean",
"description": "True if the verification passes validation, otherwise false."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Timestamp when the verification was created."
},
"issuerHandle": {
"type": "string",
"format": "handle",
"description": "The handle of the issuer."
},
"issuerDisplayName": {
"type": "string",
"description": "The display name of the issuer."
}
},
"description": "An individual verification for an associated subject."
},
"profileAssociated": {
"type": "object",
"properties": {
"chat": {
"ref": "#profileAssociatedChat",
"type": "ref"
},
"germ": {
"ref": "#profileAssociatedGerm",
"type": "ref"
},
"lists": {
"type": "integer"
},
"labeler": {
"type": "boolean"
},
"feedgens": {
"type": "integer"
},
"starterPacks": {
"type": "integer"
},
"activitySubscription": {
"ref": "#profileAssociatedActivitySubscription",
"type": "ref"
}
}
},
"verificationPrefs": {
"type": "object",
"required": [],
"properties": {
"hideBadges": {
"type": "boolean",
"default": false,
"description": "Hide the blue check badges for verified accounts and trusted verifiers."
}
},
"description": "Preferences for how verified accounts appear in the app."
},
"verificationState": {
"type": "object",
"required": [
"verifications",
"verifiedStatus",
"trustedVerifierStatus"
],
"properties": {
"verifications": {
"type": "array",
"items": {
"ref": "#verificationView",
"type": "ref"
},
"description": "All verifications issued by trusted verifiers on behalf of this user. Verifications by untrusted verifiers are not included."
},
"verifiedStatus": {
"type": "string",
"description": "The user's status as a verified account.",
"knownValues": [
"valid",
"invalid",
"none"
]
},
"trustedVerifierStatus": {
"type": "string",
"description": "The user's status as a trusted verifier.",
"knownValues": [
"valid",
"invalid",
"none"
]
}
},
"description": "Represents the verification information about the user this object is attached to."
},
"personalDetailsPref": {
"type": "object",
"properties": {
"birthDate": {
"type": "string",
"format": "datetime",
"description": "The birth date of account owner."
}
}
},
"profileViewDetailed": {
"type": "object",
"required": [
"did",
"handle"
],
"properties": {
"did": {
"type": "string",
"format": "did"
},
"debug": {
"type": "unknown",
"description": "Debug information for internal development"
},
"avatar": {
"type": "string",
"format": "uri"
},
"banner": {
"type": "string",
"format": "uri"
},
"handle": {
"type": "string",
"format": "handle"
},
"labels": {
"type": "array",
"items": {
"ref": "com.atproto.label.defs#label",
"type": "ref"
}
},
"status": {
"ref": "#statusView",
"type": "ref"
},
"viewer": {
"ref": "#viewerState",
"type": "ref"
},
"website": {
"type": "string",
"format": "uri"
},
"pronouns": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "datetime"
},
"indexedAt": {
"type": "string",
"format": "datetime"
},
"associated": {
"ref": "#profileAssociated",
"type": "ref"
},
"pinnedPost": {
"ref": "com.atproto.repo.strongRef",
"type": "ref"
},
"postsCount": {
"type": "integer"
},
"description": {
"type": "string",
"maxLength": 2560,
"maxGraphemes": 256
},
"displayName": {
"type": "string",
"maxLength": 640,
"maxGraphemes": 64
},
"followsCount": {
"type": "integer"
},
"verification": {
"ref": "#verificationState",
"type": "ref"
},
"followersCount": {
"type": "integer"
},
"joinedViaStarterPack": {
"ref": "app.bsky.graph.defs#starterPackViewBasic",
"type": "ref"
}
}
},
"bskyAppProgressGuide": {
"type": "object",
"required": [
"guide"
],
"properties": {
"guide": {
"type": "string",
"maxLength": 100
}
},
"description": "If set, an active progress guide. Once completed, can be set to undefined. Should have unspecced fields tracking progress."
},
"liveEventPreferences": {
"type": "object",
"properties": {
"hideAllFeeds": {
"type": "boolean",
"default": false,
"description": "Whether to hide all feeds from live events."
},
"hiddenFeedIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "A list of feed IDs that the user has hidden from live events."
}
},
"description": "Preferences for live events."
},
"profileAssociatedChat": {
"type": "object",
"required": [
"allowIncoming"
],
"properties": {
"allowIncoming": {
"type": "string",
"knownValues": [
"all",
"none",
"following"
]
},
"allowGroupInvites": {
"type": "string",
"knownValues": [
"all",
"none",
"following"
]
}
}
},
"profileAssociatedGerm": {
"type": "object",
"required": [
"showButtonTo",
"messageMeUrl"
],
"properties": {
"messageMeUrl": {
"type": "string",
"format": "uri"
},
"showButtonTo": {
"type": "string",
"knownValues": [
"usersIFollow",
"everyone"
]
}
}
},
"postInteractionSettingsPref": {
"type": "object",
"required": [],
"properties": {
"threadgateAllowRules": {
"type": "array",
"items": {
"refs": [
"app.bsky.feed.threadgate#mentionRule",
"app.bsky.feed.threadgate#followerRule",
"app.bsky.feed.threadgate#followingRule",
"app.bsky.feed.threadgate#listRule"
],
"type": "union"
},
"maxLength": 5,
"description": "Matches threadgate record. List of rules defining who can reply to this users posts. If value is an empty array, no one can reply. If value is undefined, anyone can reply."
},
"postgateEmbeddingRules": {
"type": "array",
"items": {
"refs": [
"app.bsky.feed.postgate#disableRule"
],
"type": "union"
},
"maxLength": 5,
"description": "Matches postgate record. List of rules defining who can embed this users posts. If value is an empty array or is undefined, no particular rules apply and anyone can embed."
}
},
"description": "Default post interaction settings for the account. These values should be applied as default values when creating new posts. These refs should mirror the threadgate and postgate records exactly."
},
"profileAssociatedActivitySubscription": {
"type": "object",
"required": [
"allowSubscriptions"
],
"properties": {
"allowSubscriptions": {
"type": "string",
"knownValues": [
"followers",
"mutuals",
"none"
]
}
}
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,30 @@
{
"id": "app.bsky.actor.getPreferences",
"defs": {
"main": {
"type": "query",
"output": {
"schema": {
"type": "object",
"required": [
"preferences"
],
"properties": {
"preferences": {
"ref": "app.bsky.actor.defs#preferences",
"type": "ref"
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"properties": {}
},
"description": "Get private preferences attached to the current account. Expected use is synchronization between multiple devices, and import/export during account migration. Requires auth."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+31
View File
@@ -0,0 +1,31 @@
{
"id": "app.bsky.actor.getProfile",
"defs": {
"main": {
"type": "query",
"output": {
"schema": {
"ref": "app.bsky.actor.defs#profileViewDetailed",
"type": "ref"
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"required": [
"actor"
],
"properties": {
"actor": {
"type": "string",
"format": "at-identifier",
"description": "Handle or DID of account to fetch profile of."
}
}
},
"description": "Get detailed profile view of an actor. Does not require auth, but contains relevant metadata with auth."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+45
View File
@@ -0,0 +1,45 @@
{
"id": "app.bsky.actor.getProfiles",
"defs": {
"main": {
"type": "query",
"output": {
"schema": {
"type": "object",
"required": [
"profiles"
],
"properties": {
"profiles": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#profileViewDetailed",
"type": "ref"
}
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"required": [
"actors"
],
"properties": {
"actors": {
"type": "array",
"items": {
"type": "string",
"format": "at-identifier"
},
"maxLength": 25
}
}
},
"description": "Get detailed profile views of multiple actors."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,54 @@
{
"id": "app.bsky.actor.getSuggestions",
"defs": {
"main": {
"type": "query",
"output": {
"schema": {
"type": "object",
"required": [
"actors"
],
"properties": {
"recId": {
"type": "integer",
"description": "DEPRECATED: use recIdStr instead."
},
"actors": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#profileView",
"type": "ref"
}
},
"cursor": {
"type": "string"
},
"recIdStr": {
"type": "string",
"description": "Snowflake for this recommendation, use when submitting recommendation events."
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"properties": {
"limit": {
"type": "integer",
"default": 50,
"maximum": 100,
"minimum": 1
},
"cursor": {
"type": "string"
}
}
},
"description": "Get a list of suggested actors. Expected use is discovery of accounts to follow during new account onboarding."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+75
View File
@@ -0,0 +1,75 @@
{
"id": "app.bsky.actor.profile",
"defs": {
"main": {
"key": "literal:self",
"type": "record",
"record": {
"type": "object",
"properties": {
"avatar": {
"type": "blob",
"accept": [
"image/png",
"image/jpeg"
],
"maxSize": 1000000,
"description": "Small image to be displayed next to posts from account. AKA, 'profile picture'"
},
"banner": {
"type": "blob",
"accept": [
"image/png",
"image/jpeg"
],
"maxSize": 1000000,
"description": "Larger horizontal image to display behind profile view."
},
"labels": {
"refs": [
"com.atproto.label.defs#selfLabels"
],
"type": "union",
"description": "Self-label values, specific to the Bluesky application, on the overall account."
},
"website": {
"type": "string",
"format": "uri"
},
"pronouns": {
"type": "string",
"maxLength": 200,
"description": "Free-form pronouns text.",
"maxGraphemes": 20
},
"createdAt": {
"type": "string",
"format": "datetime"
},
"pinnedPost": {
"ref": "com.atproto.repo.strongRef",
"type": "ref"
},
"description": {
"type": "string",
"maxLength": 2560,
"description": "Free-form profile description text.",
"maxGraphemes": 256
},
"displayName": {
"type": "string",
"maxLength": 640,
"maxGraphemes": 64
},
"joinedViaStarterPack": {
"ref": "com.atproto.repo.strongRef",
"type": "ref"
}
}
},
"description": "A declaration of a Bluesky account profile."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,26 @@
{
"id": "app.bsky.actor.putPreferences",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"preferences"
],
"properties": {
"preferences": {
"ref": "app.bsky.actor.defs#preferences",
"type": "ref"
}
}
},
"encoding": "application/json"
},
"description": "Set the private preferences attached to the account."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+54
View File
@@ -0,0 +1,54 @@
{
"id": "app.bsky.actor.searchActors",
"defs": {
"main": {
"type": "query",
"output": {
"schema": {
"type": "object",
"required": [
"actors"
],
"properties": {
"actors": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#profileView",
"type": "ref"
}
},
"cursor": {
"type": "string"
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"properties": {
"q": {
"type": "string",
"description": "Search query string. Syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended."
},
"term": {
"type": "string",
"description": "DEPRECATED: use 'q' instead."
},
"limit": {
"type": "integer",
"default": 25,
"maximum": 100,
"minimum": 1
},
"cursor": {
"type": "string"
}
}
},
"description": "Find actors (profiles) matching search criteria. Does not require auth."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,48 @@
{
"id": "app.bsky.actor.searchActorsTypeahead",
"defs": {
"main": {
"type": "query",
"output": {
"schema": {
"type": "object",
"required": [
"actors"
],
"properties": {
"actors": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#profileViewBasic",
"type": "ref"
}
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"properties": {
"q": {
"type": "string",
"description": "Search query prefix; not a full query string."
},
"term": {
"type": "string",
"description": "DEPRECATED: use 'q' instead."
},
"limit": {
"type": "integer",
"default": 10,
"maximum": 100,
"minimum": 1
}
}
},
"description": "Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry. Does not require auth."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+48
View File
@@ -0,0 +1,48 @@
{
"id": "app.bsky.actor.status",
"defs": {
"live": {
"type": "token",
"description": "Advertises an account as currently offering live content."
},
"main": {
"key": "literal:self",
"type": "record",
"record": {
"type": "object",
"required": [
"status",
"createdAt"
],
"properties": {
"embed": {
"refs": [
"app.bsky.embed.external"
],
"type": "union",
"description": "An optional embed associated with the status."
},
"status": {
"type": "string",
"description": "The status for the account.",
"knownValues": [
"app.bsky.actor.status#live"
]
},
"createdAt": {
"type": "string",
"format": "datetime"
},
"durationMinutes": {
"type": "integer",
"minimum": 1,
"description": "The duration of the status in minutes. Applications can choose to impose minimum and maximum limits."
}
}
},
"description": "A declaration of a Bluesky account status."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+61
View File
@@ -0,0 +1,61 @@
{
"id": "app.bsky.ageassurance.begin",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"email",
"language",
"countryCode"
],
"properties": {
"email": {
"type": "string",
"description": "The user's email address to receive Age Assurance instructions."
},
"language": {
"type": "string",
"description": "The user's preferred language for communication during the Age Assurance process."
},
"regionCode": {
"type": "string",
"description": "An optional ISO 3166-2 code of the user's region or state within the country."
},
"countryCode": {
"type": "string",
"description": "An ISO 3166-1 alpha-2 code of the user's location."
}
}
},
"encoding": "application/json"
},
"errors": [
{
"name": "InvalidEmail"
},
{
"name": "DidTooLong"
},
{
"name": "InvalidInitiation"
},
{
"name": "RegionNotSupported"
}
],
"output": {
"schema": {
"ref": "app.bsky.ageassurance.defs#state",
"type": "ref"
},
"encoding": "application/json"
},
"description": "Initiate Age Assurance for an account."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+333
View File
@@ -0,0 +1,333 @@
{
"id": "app.bsky.ageassurance.defs",
"defs": {
"event": {
"type": "object",
"required": [
"createdAt",
"status",
"access",
"attemptId",
"countryCode"
],
"properties": {
"email": {
"type": "string",
"description": "The email used for Age Assurance."
},
"access": {
"type": "string",
"description": "The access level granted based on Age Assurance data we've processed.",
"knownValues": [
"unknown",
"none",
"safe",
"full"
]
},
"initIp": {
"type": "string",
"description": "The IP address used when initiating the Age Assurance flow."
},
"initUa": {
"type": "string",
"description": "The user agent used when initiating the Age Assurance flow."
},
"status": {
"type": "string",
"description": "The status of the Age Assurance process.",
"knownValues": [
"unknown",
"pending",
"assured",
"blocked"
]
},
"attemptId": {
"type": "string",
"description": "The unique identifier for this instance of the Age Assurance flow, in UUID format."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "The date and time of this write operation."
},
"completeIp": {
"type": "string",
"description": "The IP address used when completing the Age Assurance flow."
},
"completeUa": {
"type": "string",
"description": "The user agent used when completing the Age Assurance flow."
},
"regionCode": {
"type": "string",
"description": "The ISO 3166-2 region code provided when beginning the Age Assurance flow."
},
"countryCode": {
"type": "string",
"description": "The ISO 3166-1 alpha-2 country code provided when beginning the Age Assurance flow."
}
},
"description": "Object used to store Age Assurance data in stash."
},
"state": {
"type": "object",
"required": [
"status",
"access"
],
"properties": {
"access": {
"ref": "app.bsky.ageassurance.defs#access",
"type": "ref"
},
"status": {
"ref": "app.bsky.ageassurance.defs#status",
"type": "ref"
},
"lastInitiatedAt": {
"type": "string",
"format": "datetime",
"description": "The timestamp when this state was last updated."
}
},
"description": "The user's computed Age Assurance state."
},
"access": {
"type": "string",
"description": "The access level granted based on Age Assurance data we've processed.",
"knownValues": [
"unknown",
"none",
"safe",
"full"
]
},
"config": {
"type": "object",
"required": [
"regions"
],
"properties": {
"regions": {
"type": "array",
"items": {
"ref": "app.bsky.ageassurance.defs#configRegion",
"type": "ref"
},
"description": "The per-region Age Assurance configuration."
}
},
"description": ""
},
"status": {
"type": "string",
"description": "The status of the Age Assurance process.",
"knownValues": [
"unknown",
"pending",
"assured",
"blocked"
]
},
"configRegion": {
"type": "object",
"required": [
"countryCode",
"minAccessAge",
"rules"
],
"properties": {
"rules": {
"type": "array",
"items": {
"refs": [
"#configRegionRuleDefault",
"#configRegionRuleIfDeclaredOverAge",
"#configRegionRuleIfDeclaredUnderAge",
"#configRegionRuleIfAssuredOverAge",
"#configRegionRuleIfAssuredUnderAge",
"#configRegionRuleIfAccountNewerThan",
"#configRegionRuleIfAccountOlderThan"
],
"type": "union"
},
"description": "The ordered list of Age Assurance rules that apply to this region. Rules should be applied in order, and the first matching rule determines the access level granted. The rules array should always include a default rule as the last item."
},
"platforms": {
"type": "array",
"items": {
"type": "string",
"knownValues": [
"web",
"ios",
"android"
]
},
"description": "The platforms this configuration applies to. If omitted, the configuration applies to all platforms."
},
"regionCode": {
"type": "string",
"description": "The ISO 3166-2 region code this configuration applies to. If omitted, the configuration applies to the entire country."
},
"countryCode": {
"type": "string",
"description": "The ISO 3166-1 alpha-2 country code this configuration applies to."
},
"minAccessAge": {
"type": "integer",
"description": "The minimum age (as a whole integer) required to use Bluesky in this region."
},
"additionalVerificationMethods": {
"type": "array",
"items": {
"type": "string",
"knownValues": [
"device"
]
},
"description": "Verification methods permitted in this region in addition to the third-party (KWS) flow, which is always supported. `device` permits using the native on-device age APIs (e.g. Apple Declared Age Range, Google Play Age Signals)."
}
},
"description": "The Age Assurance configuration for a specific region."
},
"stateMetadata": {
"type": "object",
"required": [],
"properties": {
"accountCreatedAt": {
"type": "string",
"format": "datetime",
"description": "The account creation timestamp."
}
},
"description": "Additional metadata needed to compute Age Assurance state client-side."
},
"configRegionRuleDefault": {
"type": "object",
"required": [
"access"
],
"properties": {
"access": {
"ref": "app.bsky.ageassurance.defs#access",
"type": "ref"
}
},
"description": "Age Assurance rule that applies by default."
},
"configRegionRuleIfAssuredOverAge": {
"type": "object",
"required": [
"age",
"access"
],
"properties": {
"age": {
"type": "integer",
"description": "The age threshold as a whole integer."
},
"access": {
"ref": "app.bsky.ageassurance.defs#access",
"type": "ref"
}
},
"description": "Age Assurance rule that applies if the user has been assured to be equal-to or over a certain age."
},
"configRegionRuleIfAssuredUnderAge": {
"type": "object",
"required": [
"age",
"access"
],
"properties": {
"age": {
"type": "integer",
"description": "The age threshold as a whole integer."
},
"access": {
"ref": "app.bsky.ageassurance.defs#access",
"type": "ref"
}
},
"description": "Age Assurance rule that applies if the user has been assured to be under a certain age."
},
"configRegionRuleIfDeclaredOverAge": {
"type": "object",
"required": [
"age",
"access"
],
"properties": {
"age": {
"type": "integer",
"description": "The age threshold as a whole integer."
},
"access": {
"ref": "app.bsky.ageassurance.defs#access",
"type": "ref"
}
},
"description": "Age Assurance rule that applies if the user has declared themselves equal-to or over a certain age."
},
"configRegionRuleIfAccountNewerThan": {
"type": "object",
"required": [
"date",
"access"
],
"properties": {
"date": {
"type": "string",
"format": "datetime",
"description": "The date threshold as a datetime string."
},
"access": {
"ref": "app.bsky.ageassurance.defs#access",
"type": "ref"
}
},
"description": "Age Assurance rule that applies if the account is equal-to or newer than a certain date."
},
"configRegionRuleIfAccountOlderThan": {
"type": "object",
"required": [
"date",
"access"
],
"properties": {
"date": {
"type": "string",
"format": "datetime",
"description": "The date threshold as a datetime string."
},
"access": {
"ref": "app.bsky.ageassurance.defs#access",
"type": "ref"
}
},
"description": "Age Assurance rule that applies if the account is older than a certain date."
},
"configRegionRuleIfDeclaredUnderAge": {
"type": "object",
"required": [
"age",
"access"
],
"properties": {
"age": {
"type": "integer",
"description": "The age threshold as a whole integer."
},
"access": {
"ref": "app.bsky.ageassurance.defs#access",
"type": "ref"
}
},
"description": "Age Assurance rule that applies if the user has declared themselves under a certain age."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,18 @@
{
"id": "app.bsky.ageassurance.getConfig",
"defs": {
"main": {
"type": "query",
"output": {
"schema": {
"ref": "app.bsky.ageassurance.defs#config",
"type": "ref"
},
"encoding": "application/json"
},
"description": "Returns Age Assurance configuration for use on the client."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,45 @@
{
"id": "app.bsky.ageassurance.getState",
"defs": {
"main": {
"type": "query",
"output": {
"schema": {
"type": "object",
"required": [
"state",
"metadata"
],
"properties": {
"state": {
"ref": "app.bsky.ageassurance.defs#state",
"type": "ref"
},
"metadata": {
"ref": "app.bsky.ageassurance.defs#stateMetadata",
"type": "ref"
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"required": [
"countryCode"
],
"properties": {
"regionCode": {
"type": "string"
},
"countryCode": {
"type": "string"
}
}
},
"description": "Returns server-computed Age Assurance state, if available, and any additional metadata needed to compute Age Assurance state client-side."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,37 @@
{
"id": "app.bsky.bookmark.createBookmark",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"uri",
"cid"
],
"properties": {
"cid": {
"type": "string",
"format": "cid"
},
"uri": {
"type": "string",
"format": "at-uri"
}
}
},
"encoding": "application/json"
},
"errors": [
{
"name": "UnsupportedCollection",
"description": "The URI to be bookmarked is for an unsupported collection."
}
],
"description": "Creates a private bookmark for the specified record. Currently, only `app.bsky.feed.post` records are supported. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+47
View File
@@ -0,0 +1,47 @@
{
"id": "app.bsky.bookmark.defs",
"defs": {
"bookmark": {
"type": "object",
"required": [
"subject"
],
"properties": {
"subject": {
"ref": "com.atproto.repo.strongRef",
"type": "ref",
"description": "A strong ref to the record to be bookmarked. Currently, only `app.bsky.feed.post` records are supported."
}
},
"description": "Object used to store bookmark data in stash."
},
"bookmarkView": {
"type": "object",
"required": [
"subject",
"item"
],
"properties": {
"item": {
"refs": [
"app.bsky.feed.defs#blockedPost",
"app.bsky.feed.defs#notFoundPost",
"app.bsky.feed.defs#postView"
],
"type": "union"
},
"subject": {
"ref": "com.atproto.repo.strongRef",
"type": "ref",
"description": "A strong ref to the bookmarked record."
},
"createdAt": {
"type": "string",
"format": "datetime"
}
}
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,32 @@
{
"id": "app.bsky.bookmark.deleteBookmark",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"uri"
],
"properties": {
"uri": {
"type": "string",
"format": "at-uri"
}
}
},
"encoding": "application/json"
},
"errors": [
{
"name": "UnsupportedCollection",
"description": "The URI to be bookmarked is for an unsupported collection."
}
],
"description": "Deletes a private bookmark for the specified record. Currently, only `app.bsky.feed.post` records are supported. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,46 @@
{
"id": "app.bsky.bookmark.getBookmarks",
"defs": {
"main": {
"type": "query",
"output": {
"schema": {
"type": "object",
"required": [
"bookmarks"
],
"properties": {
"cursor": {
"type": "string"
},
"bookmarks": {
"type": "array",
"items": {
"ref": "app.bsky.bookmark.defs#bookmarkView",
"type": "ref"
}
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"properties": {
"limit": {
"type": "integer",
"default": 50,
"maximum": 100,
"minimum": 1
},
"cursor": {
"type": "string"
}
}
},
"description": "Gets views of records bookmarked by the authenticated user. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+67
View File
@@ -0,0 +1,67 @@
{
"id": "app.bsky.contact.defs",
"defs": {
"syncStatus": {
"type": "object",
"required": [
"syncedAt",
"matchesCount"
],
"properties": {
"syncedAt": {
"type": "string",
"format": "datetime",
"description": "Last date when contacts where imported."
},
"matchesCount": {
"type": "integer",
"minimum": 0,
"description": "Number of existing contact matches resulting of the user imports and of their imported contacts having imported the user. Matches stop being counted when the user either follows the matched contact or dismisses the match."
}
}
},
"notification": {
"type": "object",
"required": [
"from",
"to"
],
"properties": {
"to": {
"type": "string",
"format": "did",
"description": "The DID of who this notification should go to."
},
"from": {
"type": "string",
"format": "did",
"description": "The DID of who this notification comes from."
}
},
"description": "A stash object to be sent via bsync representing a notification to be created."
},
"matchAndContactIndex": {
"type": "object",
"required": [
"match",
"contactIndex"
],
"properties": {
"match": {
"ref": "app.bsky.actor.defs#profileView",
"type": "ref",
"description": "Profile of the matched user."
},
"contactIndex": {
"type": "integer",
"maximum": 999,
"minimum": 0,
"description": "The index of this match in the import contact input."
}
},
"description": "Associates a profile with the positional index of the contact import input in the call to `app.bsky.contact.importContacts`, so clients can know which phone caused a particular match."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,42 @@
{
"id": "app.bsky.contact.dismissMatch",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"subject"
],
"properties": {
"subject": {
"type": "string",
"format": "did",
"description": "The subject's DID to dismiss the match with."
}
}
},
"encoding": "application/json"
},
"errors": [
{
"name": "InvalidDid"
},
{
"name": "InternalError"
}
],
"output": {
"schema": {
"type": "object",
"properties": {}
},
"encoding": "application/json"
},
"description": "Removes a match that was found via contact import. It shouldn't appear again if the same contact is re-imported. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+60
View File
@@ -0,0 +1,60 @@
{
"id": "app.bsky.contact.getMatches",
"defs": {
"main": {
"type": "query",
"errors": [
{
"name": "InvalidDid"
},
{
"name": "InvalidLimit"
},
{
"name": "InvalidCursor"
},
{
"name": "InternalError"
}
],
"output": {
"schema": {
"type": "object",
"required": [
"matches"
],
"properties": {
"cursor": {
"type": "string"
},
"matches": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#profileView",
"type": "ref"
}
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"properties": {
"limit": {
"type": "integer",
"default": 50,
"maximum": 100,
"minimum": 1
},
"cursor": {
"type": "string"
}
}
},
"description": "Returns the matched contacts (contacts that were mutually imported). Excludes dismissed matches. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,36 @@
{
"id": "app.bsky.contact.getSyncStatus",
"defs": {
"main": {
"type": "query",
"errors": [
{
"name": "InvalidDid"
},
{
"name": "InternalError"
}
],
"output": {
"schema": {
"type": "object",
"properties": {
"syncStatus": {
"ref": "app.bsky.contact.defs#syncStatus",
"type": "ref",
"description": "If present, indicates the user has imported their contacts. If not present, indicates the user never used the feature or called `app.bsky.contact.removeData` and didn't import again since."
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"properties": {}
},
"description": "Gets the user's current contact import status. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,72 @@
{
"id": "app.bsky.contact.importContacts",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"token",
"contacts"
],
"properties": {
"token": {
"type": "string",
"description": "JWT to authenticate the call. Use the JWT received as a response to the call to `app.bsky.contact.verifyPhone`."
},
"contacts": {
"type": "array",
"items": {
"type": "string"
},
"maxLength": 1000,
"minLength": 1,
"description": "List of phone numbers in global E.164 format (e.g., '+12125550123'). Phone numbers that cannot be normalized into a valid phone number will be discarded. Should not repeat the 'phone' input used in `app.bsky.contact.verifyPhone`."
}
}
},
"encoding": "application/json"
},
"errors": [
{
"name": "InvalidDid"
},
{
"name": "InvalidContacts"
},
{
"name": "TooManyContacts"
},
{
"name": "InvalidToken"
},
{
"name": "InternalError"
}
],
"output": {
"schema": {
"type": "object",
"required": [
"matchesAndContactIndexes"
],
"properties": {
"matchesAndContactIndexes": {
"type": "array",
"items": {
"ref": "app.bsky.contact.defs#matchAndContactIndex",
"type": "ref"
},
"description": "The users that matched during import and their indexes on the input contacts, so the client can correlate with its local list."
}
}
},
"encoding": "application/json"
},
"description": "Import contacts for securely matching with other users. This follows the protocol explained in https://docs.bsky.app/blog/contact-import-rfc. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+33
View File
@@ -0,0 +1,33 @@
{
"id": "app.bsky.contact.removeData",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"properties": {}
},
"encoding": "application/json"
},
"errors": [
{
"name": "InvalidDid"
},
{
"name": "InternalError"
}
],
"output": {
"schema": {
"type": "object",
"properties": {}
},
"encoding": "application/json"
},
"description": "Removes all stored hashes used for contact matching, existing matches, and sync status. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,40 @@
{
"id": "app.bsky.contact.sendNotification",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"from",
"to"
],
"properties": {
"to": {
"type": "string",
"format": "did",
"description": "The DID of who this notification should go to."
},
"from": {
"type": "string",
"format": "did",
"description": "The DID of who this notification comes from."
}
}
},
"encoding": "application/json"
},
"output": {
"schema": {
"type": "object",
"properties": {}
},
"encoding": "application/json"
},
"description": "System endpoint to send notifications related to contact imports. Requires role authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,47 @@
{
"id": "app.bsky.contact.startPhoneVerification",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"phone"
],
"properties": {
"phone": {
"type": "string",
"description": "The phone number to receive the code via SMS."
}
}
},
"encoding": "application/json"
},
"errors": [
{
"name": "RateLimitExceeded"
},
{
"name": "InvalidDid"
},
{
"name": "InvalidPhone"
},
{
"name": "InternalError"
}
],
"output": {
"schema": {
"type": "object",
"properties": {}
},
"encoding": "application/json"
},
"description": "Starts a phone verification flow. The phone passed will receive a code via SMS that should be passed to `app.bsky.contact.verifyPhone`. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
@@ -0,0 +1,63 @@
{
"id": "app.bsky.contact.verifyPhone",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"phone",
"code"
],
"properties": {
"code": {
"type": "string",
"description": "The code received via SMS as a result of the call to `app.bsky.contact.startPhoneVerification`."
},
"phone": {
"type": "string",
"description": "The phone number to verify. Should be the same as the one passed to `app.bsky.contact.startPhoneVerification`."
}
}
},
"encoding": "application/json"
},
"errors": [
{
"name": "RateLimitExceeded"
},
{
"name": "InvalidDid"
},
{
"name": "InvalidPhone"
},
{
"name": "InvalidCode"
},
{
"name": "InternalError"
}
],
"output": {
"schema": {
"type": "object",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": "JWT to be used in a call to `app.bsky.contact.importContacts`. It is only valid for a single call."
}
}
},
"encoding": "application/json"
},
"description": "Verifies control over a phone number with a code received via SMS and starts a contact import session. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+48
View File
@@ -0,0 +1,48 @@
{
"id": "app.bsky.draft.createDraft",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"draft"
],
"properties": {
"draft": {
"ref": "app.bsky.draft.defs#draft",
"type": "ref"
}
}
},
"encoding": "application/json"
},
"errors": [
{
"name": "DraftLimitReached",
"description": "Trying to insert a new draft when the limit was already reached."
}
],
"output": {
"schema": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"format": "tid",
"description": "The ID of the created draft."
}
}
},
"encoding": "application/json"
},
"description": "Inserts a draft using private storage (stash). An upper limit of drafts might be enforced. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+296
View File
@@ -0,0 +1,296 @@
{
"id": "app.bsky.draft.defs",
"defs": {
"draft": {
"type": "object",
"required": [
"posts"
],
"properties": {
"langs": {
"type": "array",
"items": {
"type": "string",
"format": "language"
},
"maxLength": 3,
"description": "Indicates human language of posts primary text content."
},
"posts": {
"type": "array",
"items": {
"ref": "#draftPost",
"type": "ref"
},
"maxLength": 100,
"minLength": 1,
"description": "Array of draft posts that compose this draft."
},
"deviceId": {
"type": "string",
"maxLength": 100,
"description": "UUIDv4 identifier of the device that created this draft."
},
"deviceName": {
"type": "string",
"maxLength": 100,
"description": "The device and/or platform on which the draft was created."
},
"threadgateAllow": {
"type": "array",
"items": {
"refs": [
"app.bsky.feed.threadgate#mentionRule",
"app.bsky.feed.threadgate#followerRule",
"app.bsky.feed.threadgate#followingRule",
"app.bsky.feed.threadgate#listRule"
],
"type": "union"
},
"maxLength": 5,
"description": "Allow-rules for the threadgate to be created when this draft is published."
},
"postgateEmbeddingRules": {
"type": "array",
"items": {
"refs": [
"app.bsky.feed.postgate#disableRule"
],
"type": "union"
},
"maxLength": 5,
"description": "Embedding rules for the postgates to be created when this draft is published."
}
},
"description": "A draft containing an array of draft posts."
},
"draftPost": {
"type": "object",
"required": [
"text"
],
"properties": {
"text": {
"type": "string",
"maxLength": 10000,
"description": "The primary post content. It has a higher limit than post contents to allow storing a larger text that can later be refined into smaller posts.",
"maxGraphemes": 1000
},
"labels": {
"refs": [
"com.atproto.label.defs#selfLabels"
],
"type": "union",
"description": "Self-label values for this post. Effectively content warnings."
},
"embedImages": {
"type": "array",
"items": {
"ref": "#draftEmbedImage",
"type": "ref"
},
"maxLength": 4
},
"embedVideos": {
"type": "array",
"items": {
"ref": "#draftEmbedVideo",
"type": "ref"
},
"maxLength": 1
},
"embedGallery": {
"ref": "#draftEmbedGallery",
"type": "ref"
},
"embedRecords": {
"type": "array",
"items": {
"ref": "#draftEmbedRecord",
"type": "ref"
},
"maxLength": 1
},
"embedExternals": {
"type": "array",
"items": {
"ref": "#draftEmbedExternal",
"type": "ref"
},
"maxLength": 1
}
},
"description": "One of the posts that compose a draft."
},
"draftView": {
"type": "object",
"required": [
"id",
"draft",
"createdAt",
"updatedAt"
],
"properties": {
"id": {
"type": "string",
"format": "tid",
"description": "A TID to be used as a draft identifier."
},
"draft": {
"ref": "#draft",
"type": "ref"
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "The time the draft was created."
},
"updatedAt": {
"type": "string",
"format": "datetime",
"description": "The time the draft was last updated."
}
},
"description": "View to present drafts data to users."
},
"draftWithId": {
"type": "object",
"required": [
"id",
"draft"
],
"properties": {
"id": {
"type": "string",
"format": "tid",
"description": "A TID to be used as a draft identifier."
},
"draft": {
"ref": "#draft",
"type": "ref"
}
},
"description": "A draft with an identifier, used to store drafts in private storage (stash)."
},
"draftEmbedImage": {
"type": "object",
"required": [
"localRef"
],
"properties": {
"alt": {
"type": "string",
"maxGraphemes": 2000
},
"localRef": {
"ref": "#draftEmbedLocalRef",
"type": "ref"
}
}
},
"draftEmbedVideo": {
"type": "object",
"required": [
"localRef"
],
"properties": {
"alt": {
"type": "string",
"maxGraphemes": 2000
},
"captions": {
"type": "array",
"items": {
"ref": "#draftEmbedCaption",
"type": "ref"
},
"maxLength": 20
},
"localRef": {
"ref": "#draftEmbedLocalRef",
"type": "ref"
}
}
},
"draftEmbedRecord": {
"type": "object",
"required": [
"record"
],
"properties": {
"record": {
"ref": "com.atproto.repo.strongRef",
"type": "ref"
}
}
},
"draftEmbedCaption": {
"type": "object",
"required": [
"lang",
"content"
],
"properties": {
"lang": {
"type": "string",
"format": "language"
},
"content": {
"type": "string",
"maxLength": 10000
}
}
},
"draftEmbedGallery": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"ref": "#draftEmbedGalleryItems",
"type": "ref"
}
}
},
"draftEmbedExternal": {
"type": "object",
"required": [
"uri"
],
"properties": {
"uri": {
"type": "string",
"format": "uri"
}
}
},
"draftEmbedLocalRef": {
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string",
"maxLength": 1024,
"minLength": 1,
"description": "Local, on-device ref to file to be embedded. Embeds are currently device-bound for drafts."
}
}
},
"draftEmbedGalleryItems": {
"type": "array",
"items": {
"refs": [
"#draftEmbedImage"
],
"type": "union"
},
"maxLength": 20,
"description": "The schema-level maxLength of 20 is a future-proof ceiling. Clients should currently enforce a soft limit of 10 items in authoring UIs."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+26
View File
@@ -0,0 +1,26 @@
{
"id": "app.bsky.draft.deleteDraft",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"format": "tid"
}
}
},
"encoding": "application/json"
},
"description": "Deletes a draft by ID. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+46
View File
@@ -0,0 +1,46 @@
{
"id": "app.bsky.draft.getDrafts",
"defs": {
"main": {
"type": "query",
"output": {
"schema": {
"type": "object",
"required": [
"drafts"
],
"properties": {
"cursor": {
"type": "string"
},
"drafts": {
"type": "array",
"items": {
"ref": "app.bsky.draft.defs#draftView",
"type": "ref"
}
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"properties": {
"limit": {
"type": "integer",
"default": 50,
"maximum": 100,
"minimum": 1
},
"cursor": {
"type": "string"
}
}
},
"description": "Gets views of user drafts. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+26
View File
@@ -0,0 +1,26 @@
{
"id": "app.bsky.draft.updateDraft",
"defs": {
"main": {
"type": "procedure",
"input": {
"schema": {
"type": "object",
"required": [
"draft"
],
"properties": {
"draft": {
"ref": "app.bsky.draft.defs#draftWithId",
"type": "ref"
}
}
},
"encoding": "application/json"
},
"description": "Updates a draft using private storage (stash). If the draft ID points to a non-existing ID, the update will be silently ignored. This is done because updates don't enforce draft limit, so it accepts all writes, but will ignore invalid ones. Requires authentication."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+25
View File
@@ -0,0 +1,25 @@
{
"id": "app.bsky.embed.defs",
"defs": {
"aspectRatio": {
"type": "object",
"required": [
"width",
"height"
],
"properties": {
"width": {
"type": "integer",
"minimum": 1
},
"height": {
"type": "integer",
"minimum": 1
}
},
"description": "width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}

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