Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey e8eac442b1 Disable overscroll on Android to fix settle discrepancy 2026-06-03 17:48:08 -05:00
366 changed files with 75066 additions and 144130 deletions
+1 -1
View File
@@ -51,4 +51,4 @@ jobs:
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-8
--model claude-opus-4-7
-2
View File
@@ -20,7 +20,6 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
@@ -38,7 +37,6 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
+10 -2
View File
@@ -57,7 +57,11 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
run: pnpm install --frozen-lockfile
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Check & compile i18n
run: pnpm intl:build
- name: Lint checks
@@ -95,7 +99,11 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
run: pnpm install --frozen-lockfile
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Check & compile i18n
run: pnpm intl:build
- name: Run tests
@@ -26,7 +26,11 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
run: pnpm install --frozen-lockfile
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Extract language strings
run: pnpm intl:extract
- name: Create commit
+6 -2
View File
@@ -34,8 +34,12 @@ jobs:
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
- name: pnpm install
# Fine to skip scripts since we don't run any code
run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
uses: Wandalen/wretry.action@master
with:
# Fine to skip scripts since we don't run any code
command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Verify pnpm-lock.yaml
run: |
-1
View File
@@ -133,4 +133,3 @@ bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
.claude/worktrees
-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
@@ -9,7 +9,7 @@ WORKDIR /app
COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/pnpm-lock.yaml ./
COPY ./bskyogcard/pnpm-workspace.yaml ./
RUN npm install --global pnpm@11.5.2
RUN npm install --global pnpm@11.1.3
RUN pnpm install --frozen-lockfile
COPY ./bskyogcard ./
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global pnpm@11.5.2 && \
npm install --global pnpm@11.5.0 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
+12 -23
View File
@@ -1,12 +1,11 @@
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
import {
downloadAndResize,
type DownloadAndResizeOpts,
getResizedDimensions,
} from '../../src/lib/media/manip'
import {getResizedDimensions} from '../../src/lib/media/util'
const mockResizedImage = {
path: 'file://resized-image.jpg',
@@ -42,8 +41,10 @@ describe('downloadAndResize', () => {
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.jpg',
maxDimension: 2000,
width: 100,
height: 100,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
@@ -59,11 +60,9 @@ describe('downloadAndResize', () => {
// First time it gets called is to get dimensions
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
// The mocked source image is 100x100, below maxDimension, so it is not
// downsized.
expect(manipulateAsync).toHaveBeenCalledWith(
expect.any(String),
[{resize: {height: 100, width: 100}}],
[{resize: {height: opts.height, width: opts.width}}],
{format: SaveFormat.JPEG, compress: 1.0},
)
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
@@ -74,8 +73,10 @@ describe('downloadAndResize', () => {
it('should return undefined for invalid URI', async () => {
const opts: DownloadAndResizeOpts = {
uri: 'invalid-uri',
maxDimension: 2000,
width: 100,
height: 100,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
@@ -89,19 +90,13 @@ describe('downloadAndResize', () => {
width: 1200,
height: 1000,
}
const resizedDimensionsOne = getResizedDimensions(
initialDimensionsOne,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
const initialDimensionsTwo = {
width: 1000,
height: 1200,
}
const resizedDimensionsTwo = getResizedDimensions(
initialDimensionsTwo,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
@@ -112,19 +107,13 @@ describe('downloadAndResize', () => {
width: 3000,
height: 1500,
}
const resizedDimensionsOne = getResizedDimensions(
initialDimensionsOne,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
const initialDimensionsTwo = {
width: 2000,
height: 4000,
}
const resizedDimensionsTwo = getResizedDimensions(
initialDimensionsTwo,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
expect(resizedDimensionsOne).toEqual({
width: 2000,
-45
View File
@@ -1,7 +1,6 @@
import {describe, expect, it} from '@jest/globals'
import {
getChatInviteCodeFromUrl,
isPossiblyAUrl,
isTrustedUrl,
linkRequiresWarning,
@@ -179,47 +178,3 @@ describe('isTrustedUrl', () => {
expect(output).toEqual(expected)
})
})
describe('getChatInviteCodeFromUrl', () => {
type Case = [string, string | undefined]
const cases: Case[] = [
['https://bsky.app/chat/abcdefg', 'abcdefg'],
['https://bsky.app/chat/abcdefghij', 'abcdefghij'],
// http is not recognized as a bsky.app url
['http://bsky.app/chat/abcdefg', undefined],
['https://bsky.app/chat/abcdefg?utm=foo', 'abcdefg'],
['https://bsky.app/chat/abcdefg#section', 'abcdefg'],
['/chat/abcdefg', 'abcdefg'],
['/chat/abcdefg?utm=foo', 'abcdefg'],
['/chat/abcdefg#section', 'abcdefg'],
// too short
['https://bsky.app/chat/abcdef', undefined],
['/chat/abcdef', undefined],
// too long
['https://bsky.app/chat/abcdefghijk', undefined],
['/chat/abcdefghijk', undefined],
// invalid characters
['https://bsky.app/chat/abc-def', undefined],
['/chat/abc def', undefined],
// trailing path
['https://bsky.app/chat/abcdefg/extra', undefined],
['/chat/abcdefg/extra', undefined],
// wrong path
['https://bsky.app/profile/abcdefg', undefined],
['https://bsky.app/chat', undefined],
// wrong host
['https://example.com/chat/abcdefg', undefined],
// not a url, not a path
['chat/abcdefg', undefined],
['abcdefg', undefined],
['', undefined],
// malformed url
['https://[invalid/chat/abcdefg', undefined],
]
it.each(cases)('given input %p, returns %p', (input, expected) => {
expect(getChatInviteCodeFromUrl(input)).toEqual(expected)
})
})
+1 -1
View File
@@ -349,7 +349,7 @@ module.exports = function (_config) {
},
],
[
'@bsky.app/expo-dynamic-app-icon',
'@mozzius/expo-dynamic-app-icon',
{
/**
* Default set
Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

+1 -1
View File
@@ -13,7 +13,7 @@
"format": "prettier -w src"
},
"dependencies": {
"@atproto/api": "0.20.11",
"@atproto/api": "^0.15.25",
"preact": "^10.4.8"
},
"devDependencies": {
+67 -56
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.11
version: 0.20.11
specifier: ^0.15.25
version: 0.15.27
preact:
specifier: ^10.4.8
version: 10.29.1
@@ -73,33 +73,32 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@atproto/api@0.20.11':
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
engines: {node: '>=22'}
'@atproto/api@0.15.27':
resolution: {integrity: sha512-ok/WGafh1nz4t8pEQGtAF/32x2E2VDWU4af6BajkO5Gky2jp2q6cv6aB2A5yuvNNcc3XkYMYipsqVHVwLPMF9g==}
'@atproto/common-web@0.5.0':
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
engines: {node: '>=22'}
'@atproto/common-web@0.4.21':
resolution: {integrity: sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==}
'@atproto/lex-data@0.1.1':
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
engines: {node: '>=22'}
'@atproto/lex-data@0.0.15':
resolution: {integrity: sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==}
'@atproto/lex-json@0.1.0':
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
engines: {node: '>=22'}
'@atproto/lex-json@0.0.16':
resolution: {integrity: sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==}
'@atproto/lexicon@0.7.1':
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
engines: {node: '>=22'}
'@atproto/lexicon@0.4.14':
resolution: {integrity: sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==}
'@atproto/syntax@0.6.1':
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'}
'@atproto/lexicon@0.6.2':
resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==}
'@atproto/xrpc@0.8.0':
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
engines: {node: '>=22'}
'@atproto/syntax@0.4.3':
resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==}
'@atproto/syntax@0.5.4':
resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==}
'@atproto/xrpc@0.7.7':
resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
@@ -1102,8 +1101,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
await-lock@3.0.0:
resolution: {integrity: sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==}
await-lock@2.2.2:
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
babel-plugin-polyfill-corejs2@0.4.17:
resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==}
@@ -1788,8 +1787,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multiformats@13.4.2:
resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==}
multiformats@9.9.0:
resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -2195,8 +2194,8 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uint8arrays@5.1.1:
resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==}
uint8arrays@3.0.0:
resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==}
unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
@@ -2325,51 +2324,63 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@atproto/api@0.20.11':
'@atproto/api@0.15.27':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
'@atproto/syntax': 0.6.1
'@atproto/xrpc': 0.8.0
await-lock: 3.0.0
multiformats: 13.4.2
'@atproto/common-web': 0.4.21
'@atproto/lexicon': 0.4.14
'@atproto/syntax': 0.4.3
'@atproto/xrpc': 0.7.7
await-lock: 2.2.2
multiformats: 9.9.0
tlds: 1.261.0
zod: 3.25.76
'@atproto/common-web@0.5.0':
'@atproto/common-web@0.4.21':
dependencies:
'@atproto/lex-data': 0.1.1
'@atproto/lex-json': 0.1.0
'@atproto/syntax': 0.6.1
'@atproto/lex-data': 0.0.15
'@atproto/lex-json': 0.0.16
'@atproto/syntax': 0.5.4
zod: 3.25.76
'@atproto/lex-data@0.1.1':
'@atproto/lex-data@0.0.15':
dependencies:
multiformats: 13.4.2
multiformats: 9.9.0
tslib: 2.8.1
uint8arrays: 5.1.1
uint8arrays: 3.0.0
unicode-segmenter: 0.14.5
'@atproto/lex-json@0.1.0':
'@atproto/lex-json@0.0.16':
dependencies:
'@atproto/lex-data': 0.1.1
'@atproto/lex-data': 0.0.15
tslib: 2.8.1
'@atproto/lexicon@0.7.1':
'@atproto/lexicon@0.4.14':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/syntax': 0.6.1
multiformats: 13.4.2
'@atproto/common-web': 0.4.21
'@atproto/syntax': 0.4.3
iso-datestring-validator: 2.2.2
multiformats: 9.9.0
zod: 3.25.76
'@atproto/syntax@0.6.1':
'@atproto/lexicon@0.6.2':
dependencies:
'@atproto/common-web': 0.4.21
'@atproto/syntax': 0.5.4
iso-datestring-validator: 2.2.2
multiformats: 9.9.0
zod: 3.25.76
'@atproto/syntax@0.4.3':
dependencies:
tslib: 2.8.1
'@atproto/xrpc@0.8.0':
'@atproto/syntax@0.5.4':
dependencies:
'@atproto/lexicon': 0.7.1
tslib: 2.8.1
'@atproto/xrpc@0.7.7':
dependencies:
'@atproto/lexicon': 0.6.2
zod: 3.25.76
'@babel/code-frame@7.29.0':
@@ -3525,7 +3536,7 @@ snapshots:
postcss: 8.5.14
postcss-value-parser: 4.2.0
await-lock@3.0.0: {}
await-lock@2.2.2: {}
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0):
dependencies:
@@ -4131,7 +4142,7 @@ snapshots:
ms@2.1.3: {}
multiformats@13.4.2: {}
multiformats@9.9.0: {}
mz@2.7.0:
dependencies:
@@ -4535,9 +4546,9 @@ snapshots:
typescript@6.0.3: {}
uint8arrays@5.1.1:
uint8arrays@3.0.0:
dependencies:
multiformats: 13.4.2
multiformats: 9.9.0
unicode-canonical-property-names-ecmascript@2.0.1: {}
+18 -66
View File
@@ -1,6 +1,5 @@
import {
AppBskyEmbedExternal,
AppBskyEmbedGallery,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
@@ -44,11 +43,6 @@ export function Embed({
return <ImageEmbed content={content} labelInfo={labelInfo} />
}
// Case 1b: Gallery (Photos v2)
if (AppBskyEmbedGallery.isView(content)) {
return <GalleryEmbed content={content} labelInfo={labelInfo} />
}
// Case 2: External link
if (AppBskyEmbedExternal.isView(content)) {
return <ExternalEmbed content={content} labelInfo={labelInfo} />
@@ -236,8 +230,6 @@ function Info({children}: {children: ComponentChildren}) {
)
}
type GridImage = {thumb: string; alt: string}
function ImageEmbed({
content,
labelInfo,
@@ -248,45 +240,20 @@ function ImageEmbed({
if (labelInfo) {
return <Info>{labelInfo}</Info>
}
return (
<ImageGrid
images={content.images.map(i => ({thumb: i.thumb, alt: i.alt}))}
/>
)
}
function GalleryEmbed({
content,
labelInfo,
}: {
content: AppBskyEmbedGallery.View
labelInfo?: string
}) {
if (labelInfo) {
return <Info>{labelInfo}</Info>
}
const images = content.items
.filter(AppBskyEmbedGallery.isViewImage)
.map(i => ({thumb: i.thumbnail, alt: i.alt}))
return <ImageGrid images={images} />
}
function ImageGrid({images}: {images: GridImage[]}) {
switch (images.length) {
case 0:
return null
switch (content.images.length) {
case 1:
return (
<img
src={images[0].thumb}
alt={images[0].alt}
src={content.images[0].thumb}
alt={content.images[0].alt}
className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]"
/>
)
case 2:
return (
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
{images.map((image, i) => (
{content.images.map((image, i) => (
<img
key={i}
src={image.thumb}
@@ -301,13 +268,13 @@ function ImageGrid({images}: {images: GridImage[]}) {
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
<div className="flex-1 aspect-square">
<img
src={images[0].thumb}
alt={images[0].alt}
src={content.images[0].thumb}
alt={content.images[0].alt}
className="w-full h-full object-cover rounded-sm"
/>
</div>
<div className="flex flex-col gap-1 flex-1">
{images.slice(1).map((image, i) => (
{content.images.slice(1).map((image, i) => (
<img
key={i}
src={image.thumb}
@@ -318,36 +285,21 @@ function ImageGrid({images}: {images: GridImage[]}) {
</div>
</div>
)
default: {
const remaining = images.length - 4
case 4:
return (
<div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
{images.slice(0, 4).map((image, i) => {
const isOverflowCell = i === 3 && remaining > 0
return (
<div
key={i}
className="relative aspect-[3/2] rounded-sm overflow-hidden">
<img
src={image.thumb}
alt={image.alt}
className="absolute inset-0 w-full h-full object-cover"
/>
{isOverflowCell && (
<div
aria-label={`+${remaining} more image${remaining === 1 ? '' : 's'}, view post to see all`}
className="absolute inset-0 flex items-center justify-center bg-black/50">
<span className="text-white text-2xl font-semibold">
+{remaining}
</span>
</div>
)}
</div>
)
})}
{content.images.map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="aspect-[3/2] w-full object-cover rounded-sm"
/>
))}
</div>
)
}
default:
return null
}
}
+1 -3
View File
@@ -154,9 +154,7 @@ export function Post({thread}: Props) {
}
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
// text-only check - posts with no text (e.g. gallery posts) would otherwise
// render an empty <p> that adds an extra flex gap above the embed
if (!record?.text) return null
if (!record) return null
const rt = new RichText({
text: record.text,
+2 -2
View File
@@ -6,7 +6,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.5.2",
"version": "11.1.3",
"onFail": "warn"
}
},
@@ -18,7 +18,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@atproto/api": "0.20.13",
"@atproto/api": "0.20.6",
"@atproto/common": "^0.6.1",
"@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2",
+43 -43
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 11.5.2
version: 11.5.2
specifier: 11.1.3
version: 11.1.3
pnpm:
specifier: 11.5.2
version: 11.5.2
specifier: 11.1.3
version: 11.1.3
packages:
'@pnpm/exe@11.5.2':
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
'@pnpm/exe@11.1.3':
resolution: {integrity: sha512-J6bSMpZlHVUKuMKtPT/+lrFPpvBiOIgk6HnNC3vmoM/fsMgFhao6ITFwdsUMzdCl2qHf9cnVYA4ZBdsGmVUnpg==}
hasBin: true
'@pnpm/linux-arm64@11.5.2':
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
'@pnpm/linux-arm64@11.1.3':
resolution: {integrity: sha512-sz3fc0hSqguk2eGe/InelpBD3LP82MzF+8pLzDpYNGuhasGY+VWkuxEo02HczQYRVXTsbpZVepk+Qs2CONc04Q==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.5.2':
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
'@pnpm/linux-x64@11.1.3':
resolution: {integrity: sha512-UadJh5fJZWa47OtdZTWLKWDj4z5WpZFB5pS2wOh/kfKypUmQyjmbOMClP7/yJGS2ZtrSjRgYjIEWAjndkWsMaA==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.5.2':
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
'@pnpm/linuxstatic-arm64@11.1.3':
resolution: {integrity: sha512-lWmGr96w+VrIRVsEfTXROB3GeQNxrX2Hy32j3USHr6WlqmpH1i7YjavJGpoXeVZbb77fCFjNFAtsJzGXSgy/Og==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.5.2':
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
'@pnpm/linuxstatic-x64@11.1.3':
resolution: {integrity: sha512-I74GDBOPbr5TXgob3ct4hv6BQtLGdsjGJrII2qNl/e2xYHXekjIWE4Eh7RGN4C7xu+BZYKtvnOOr149yR7KxUw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.5.2':
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
'@pnpm/macos-arm64@11.1.3':
resolution: {integrity: sha512-nWn155BVa54iNyg4iolVhjMtqumXHPh8ul5CFjQFJXdwgr9MRUgc5EUBML1+7mS8C/7Wcex5HUgn/w3fliHCsQ==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.5.2':
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
'@pnpm/win-arm64@11.1.3':
resolution: {integrity: sha512-4u6PQL7/WgwdweC2ZJcdCzhM1koRFG5ofcUSrIVsdyo9ePfGq2D9p1rkZbiLEfIGvl3GKOsfMB5DRTgs8es4AQ==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.5.2':
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
'@pnpm/win-x64@11.1.3':
resolution: {integrity: sha512-JmhH7ljJ3MWjvWFz/YHbu/27ISCNLZsQb1JG+ib3uHlfUwIJuF6BudXYsbS5Uu0o7xUvC9sPPm+Uho4Or6R3vA==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.5.2:
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
pnpm@11.1.3:
resolution: {integrity: sha512-yFNX/hfKEt0j3XBxgiZm39fjy3b+IU4zcLXqL7NPKiMRhVCbY+cX880KyzjdP42CvNXoFyQArmeLcOpPvtCJbQ==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.5.2':
'@pnpm/exe@11.1.3':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.5.2
'@pnpm/linux-x64': 11.5.2
'@pnpm/linuxstatic-arm64': 11.5.2
'@pnpm/linuxstatic-x64': 11.5.2
'@pnpm/macos-arm64': 11.5.2
'@pnpm/win-arm64': 11.5.2
'@pnpm/win-x64': 11.5.2
'@pnpm/linux-arm64': 11.1.3
'@pnpm/linux-x64': 11.1.3
'@pnpm/linuxstatic-arm64': 11.1.3
'@pnpm/linuxstatic-x64': 11.1.3
'@pnpm/macos-arm64': 11.1.3
'@pnpm/win-arm64': 11.1.3
'@pnpm/win-x64': 11.1.3
'@pnpm/linux-arm64@11.5.2':
'@pnpm/linux-arm64@11.1.3':
optional: true
'@pnpm/linux-x64@11.5.2':
'@pnpm/linux-x64@11.1.3':
optional: true
'@pnpm/linuxstatic-arm64@11.5.2':
'@pnpm/linuxstatic-arm64@11.1.3':
optional: true
'@pnpm/linuxstatic-x64@11.5.2':
'@pnpm/linuxstatic-x64@11.1.3':
optional: true
'@pnpm/macos-arm64@11.5.2':
'@pnpm/macos-arm64@11.1.3':
optional: true
'@pnpm/win-arm64@11.5.2':
'@pnpm/win-arm64@11.1.3':
optional: true
'@pnpm/win-x64@11.5.2':
'@pnpm/win-x64@11.1.3':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.5.2: {}
pnpm@11.1.3: {}
---
lockfileVersion: '9.0'
@@ -208,8 +208,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.13
version: 0.20.13
specifier: 0.20.6
version: 0.20.6
'@atproto/common':
specifier: ^0.6.1
version: 0.6.1
@@ -259,8 +259,8 @@ importers:
packages:
'@atproto/api@0.20.13':
resolution: {integrity: sha512-AN7NTUsygHXnXs5Cnc6v7/BAjQydwAhE0P8hL21RpIAB4fmTksRKTU3TV//wPg4fk9ESsfNwGIAlwoybbyoZKg==}
'@atproto/api@0.20.6':
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -1154,7 +1154,7 @@ packages:
snapshots:
'@atproto/api@0.20.13':
'@atproto/api@0.20.6':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert'
import {ChatBskyGroupDefs} from '@atproto/api'
import {type ChatBskyGroupDefs} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
import satori from 'satori'
@@ -32,7 +32,7 @@ export default function (ctx: AppContext, app: Express) {
codes: [code],
})
const found = result.data.joinLinkPreviews[0]
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(found)) {
if (!found) {
return res.status(404).end('not found')
}
preview = found
+15 -4
View File
@@ -4,9 +4,10 @@ import (
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
// Helpers for extracting Open Graph metadata from post embeds. og:video
// shares findVideoEmbed with the JSON-LD VideoObject path in jsonld.go so
// the two outputs cannot drift.
// Helpers for extracting Open Graph metadata from post embeds. These feed
// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in
// jsonld.go. og:video has its own path because JSON-LD does not yet emit
// VideoObject (deferred — needs `duration` from the appview).
// videoMeta holds og:video meta tag data.
type videoMeta struct {
@@ -20,7 +21,17 @@ type videoMeta struct {
// extractVideoMeta returns og:video metadata, or the zero value if there's
// no video embed. Respects embedHidden so og:video and og:image stay in sync.
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
v := findVideoEmbed(pv, embedHidden)
if pv == nil || pv.Embed == nil || embedHidden {
return videoMeta{}
}
var v *appbsky.EmbedVideo_View
if pv.Embed.EmbedVideo_View != nil {
v = pv.Embed.EmbedVideo_View
} else if pv.Embed.EmbedRecordWithMedia_View != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
v = pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
}
if v == nil || v.Playlist == "" {
return videoMeta{}
}
+7 -144
View File
@@ -59,7 +59,6 @@ type discussionForumPosting struct {
Text string `json:"text,omitempty"`
Image []string `json:"image,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
Video *videoObject `json:"video,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
CommentCount *int64 `json:"commentCount,omitempty"`
@@ -68,20 +67,6 @@ type discussionForumPosting struct {
SharedContent *sharedContent `json:"sharedContent,omitempty"`
}
// videoObject is the schema.org VideoObject shape for video embeds.
// duration is omitted because the appview does not expose it.
type videoObject struct {
Type string `json:"@type"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
UploadDate string `json:"uploadDate,omitempty"`
ContentURL string `json:"contentUrl,omitempty"`
EmbedURL string `json:"embedUrl,omitempty"`
Width int64 `json:"width,omitempty"`
Height int64 `json:"height,omitempty"`
}
// comment is the schema.org Comment shape used in
// DiscussionForumPosting.comment[]. The comment property does not accept
// DiscussionForumPosting, so replies map to Comment.
@@ -93,7 +78,6 @@ type comment struct {
Text string `json:"text,omitempty"`
Image []string `json:"image,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
Video *videoObject `json:"video,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
}
@@ -143,31 +127,6 @@ func bskyPostURLFromATURI(handle, atURI string) string {
return bskyPostURL(handle, parsed.RecordKey().String())
}
// bskyPostURLFromATURIWithDIDFallback returns the handle-form post URL
// when the handle is usable, otherwise falls back to the DID-form URL
// derived from the AT-URI's authority. Returns "" only when the AT-URI is
// unparseable or has no record key. Used for nested fields (e.g.
// VideoObject.embedUrl) where omitting on handle.invalid would weaken the
// emitted structured data.
func bskyPostURLFromATURIWithDIDFallback(handle, atURI string) string {
parsed, err := syntax.ParseATURI(atURI)
if err != nil {
return ""
}
rkey := parsed.RecordKey().String()
if rkey == "" {
return ""
}
if url := bskyPostURL(handle, rkey); url != "" {
return url
}
did := parsed.Authority().String()
if did == "" {
return ""
}
return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", did, rkey)
}
// bskyProfileURL returns the canonical handle-form profile URL, or "" if
// the handle is unusable.
func bskyProfileURL(handle string) string {
@@ -177,9 +136,9 @@ func bskyProfileURL(handle string) string {
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
}
// extractPostMedia returns thumbnail URLs for the post's image, gallery,
// or video embed, byte-identical to what we put in og:image. Callers
// derive thumbnailUrl from urls[0].
// extractPostMedia returns thumbnail URLs for the post's image or video
// embed, byte-identical to what we put in og:image. Callers derive
// thumbnailUrl from urls[0].
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
if pv == nil || pv.Embed == nil || embedHidden {
return nil
@@ -188,9 +147,6 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
if pv.Embed.EmbedImages_View != nil {
return imageThumbs(pv.Embed.EmbedImages_View.Images)
}
if pv.Embed.EmbedGallery_View != nil {
return galleryThumbs(pv.Embed.EmbedGallery_View.Items)
}
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
}
@@ -199,9 +155,6 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
if media.EmbedImages_View != nil {
return imageThumbs(media.EmbedImages_View.Images)
}
if media.EmbedGallery_View != nil {
return galleryThumbs(media.EmbedGallery_View.Items)
}
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
return []string{*media.EmbedVideo_View.Thumbnail}
}
@@ -221,89 +174,6 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
return urls
}
// galleryThumbs returns the thumbnail URLs of image items in a gallery
// embed, or nil if empty. Items_Elem is a union; non-image variants and
// nil entries are skipped so future gallery item types don't break SEO
// extraction. Empty Thumbnail strings are also skipped to avoid emitting
// <meta property="og:image" content=""> if the appview ever returns one.
func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
if len(items) == 0 {
return nil
}
urls := make([]string, 0, len(items))
for _, item := range items {
if item == nil || item.EmbedGallery_ViewImage == nil {
continue
}
if item.EmbedGallery_ViewImage.Thumbnail == "" {
continue
}
urls = append(urls, item.EmbedGallery_ViewImage.Thumbnail)
}
if len(urls) == 0 {
return nil
}
return urls
}
// findVideoEmbed returns the post's video embed view, or nil if there is
// none or embeds are hidden. Shared with extractVideoMeta so og:video and
// JSON-LD VideoObject stay in sync.
func findVideoEmbed(pv *appbsky.FeedDefs_PostView, embedHidden bool) *appbsky.EmbedVideo_View {
if pv == nil || pv.Embed == nil || embedHidden {
return nil
}
if pv.Embed.EmbedVideo_View != nil {
return pv.Embed.EmbedVideo_View
}
if pv.Embed.EmbedRecordWithMedia_View != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
}
return nil
}
// buildVideoObject returns a VideoObject for the post's video embed, or
// nil if there's no usable video. Falls back to "Video by @<handle>" when
// alt text is empty so name is always populated (Google requires it).
// description falls back to name for the same reason.
func buildVideoObject(pv *appbsky.FeedDefs_PostView, embedURL, postText string, embedHidden bool) *videoObject {
v := findVideoEmbed(pv, embedHidden)
if v == nil || v.Playlist == "" {
return nil
}
vo := &videoObject{
Type: "VideoObject",
ContentURL: v.Playlist,
EmbedURL: embedURL,
// uploadDate uses IndexedAt (not record CreatedAt) for consistency
// with DiscussionForumPosting.datePublished on the parent post.
UploadDate: pv.IndexedAt,
}
if v.Thumbnail != nil {
vo.ThumbnailURL = *v.Thumbnail
}
switch {
case v.Alt != nil && *v.Alt != "":
vo.Name = *v.Alt
case pv.Author != nil && pv.Author.Handle != "" && pv.Author.Handle != "handle.invalid":
vo.Name = "Video by @" + pv.Author.Handle
default:
vo.Name = "Video on Bluesky"
}
if postText != "" {
vo.Description = postText
} else {
vo.Description = vo.Name
}
if v.AspectRatio != nil {
vo.Width = v.AspectRatio.Width
vo.Height = v.AspectRatio.Height
}
return vo
}
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
// if the embed is blocked / not-found / detached / a non-post record.
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
@@ -493,18 +363,14 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
thumb = images[0]
}
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
postText := postRecordText(pv)
node := discussionForumPosting{
Type: "DiscussionForumPosting",
URL: postURL,
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
Identifier: pv.Uri,
Author: buildAuthor(pv.Author),
Text: postText,
Text: postRecordText(pv),
Image: images,
ThumbnailURL: thumb,
Video: buildVideoObject(pv, postURL, postText, embedHidden),
DatePublished: pv.IndexedAt,
InteractionStat: buildPostStats(pv),
}
@@ -567,17 +433,14 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
if len(images) > 0 {
thumb = images[0]
}
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
postText := postRecordText(pv)
return comment{
Type: "Comment",
URL: postURL,
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
Identifier: pv.Uri,
Author: buildAuthor(pv.Author),
Text: postText,
Text: postRecordText(pv),
Image: images,
ThumbnailURL: thumb,
Video: buildVideoObject(pv, postURL, postText, embedHidden),
DatePublished: pv.IndexedAt,
}
}
-491
View File
@@ -72,60 +72,6 @@ func withImages(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
}
}
// withGallery adds an app.bsky.embed.gallery view with image items.
func withGallery(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
var items []*appbsky.EmbedGallery_View_Items_Elem
for _, t := range thumbs {
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
Thumbnail: t,
Fullsize: t + "_full",
},
})
}
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
}
}
}
// withRecordWithMediaGallery adds a record-with-media embed whose media slot
// is an app.bsky.embed.gallery view.
func withRecordWithMediaGallery(qHandle, qDid, qRkey string, thumbs ...string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
var items []*appbsky.EmbedGallery_View_Items_Elem
for _, t := range thumbs {
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
Thumbnail: t,
Fullsize: t + "_full",
},
})
}
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
Record: &appbsky.EmbedRecord_View{
Record: &appbsky.EmbedRecord_View_Record{
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
Uri: "at://" + qDid + "/app.bsky.feed.post/" + qRkey,
Cid: "bafy-quoted",
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: qDid,
Handle: qHandle,
},
IndexedAt: "2024-01-01T00:00:00Z",
},
},
},
Media: &appbsky.EmbedRecordWithMedia_View_Media{
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
},
},
}
}
}
// withVideo adds a video embed with a thumbnail.
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
@@ -353,88 +299,6 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
}
}
func TestBuildPostJSONLD_WithGallery(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
imgs, ok := main["image"].([]any)
if !ok {
t.Fatalf("image should be array, got %T", main["image"])
}
if len(imgs) != 3 {
t.Errorf("expected 3 gallery images, got %d", len(imgs))
}
if imgs[0] != thumb1 || imgs[1] != thumb2 || imgs[2] != thumb3 {
t.Errorf("gallery image[] order wrong: %v", imgs)
}
if main["thumbnailUrl"] != thumb1 {
t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"])
}
}
// Gallery in the media slot of a record-with-media embed should still
// produce og:image / JSON-LD image[]. Quote-post URL still emits
// alongside via isBasedOn.
func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
imgs, ok := main["image"].([]any)
if !ok || len(imgs) != 1 || imgs[0] != thumb {
t.Errorf("expected single gallery thumb in image[], got %v", main["image"])
}
if main["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl wrong: %v", main["thumbnailUrl"])
}
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn should still emit for record-with-media gallery, got %v", main["isBasedOn"])
}
}
// Forward-compat: nil items, unknown-variant union elements, and empty
// Thumbnail strings must be skipped, not panic or leak as <meta
// property="og:image" content="">. Unknown variants are dropped silently
// so older deploys keep working when new gallery item types ship.
func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery")
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{
Items: []*appbsky.EmbedGallery_View_Items_Elem{
nil,
{}, // empty union, no variant set
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: ""}}, // empty Thumbnail
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: thumb}},
},
},
}
got := extractPostMedia(pv, false)
if len(got) != 1 || got[0] != thumb {
t.Errorf("expected single thumb, got %v", got)
}
// All-nil / all-unknown gallery should produce no thumbs (not [""]).
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{
Items: []*appbsky.EmbedGallery_View_Items_Elem{nil, {}},
},
}
if got := extractPostMedia(pv, false); got != nil {
t.Errorf("expected nil for empty/unknown-only gallery, got %v", got)
}
}
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
@@ -497,25 +361,6 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
}
}
// Symmetric guard for the gallery extraction path. Functionally redundant
// with the early-return at the top of extractPostMedia, but exists so the
// hide-embed contract is asserted directly against the gallery branch -
// catches anyone who later moves the embedHidden check inside an
// embed-shape branch.
func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
thumb := "https://cdn.bsky.app/img/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withGallery(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed gallery post should not emit image")
}
if _, present := main["thumbnailUrl"]; present {
t.Errorf("hidden-embed gallery post should not emit thumbnailUrl")
}
}
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, </script>, and a unicode char.
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
@@ -1204,339 +1049,3 @@ func TestBuildProfileJSONLD_HasPartAuthorReviewedBy(t *testing.T) {
t.Errorf("verifier identifier wrong: %v", rb[0])
}
}
// videoEmbedOpts configures withVideoFull. Zero values mean "not set".
type videoEmbedOpts struct {
thumbnail string
playlist string
alt string
width int64
height int64
hasAspect bool
recordMedia bool // nest under EmbedRecordWithMedia_View.Media
}
// withVideoFull installs a fully-specified video embed for VideoObject tests.
func withVideoFull(o videoEmbedOpts) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
v := &appbsky.EmbedVideo_View{Playlist: o.playlist}
if o.thumbnail != "" {
v.Thumbnail = strPtr(o.thumbnail)
}
if o.alt != "" {
v.Alt = strPtr(o.alt)
}
if o.hasAspect {
v.AspectRatio = &appbsky.EmbedDefs_AspectRatio{Width: o.width, Height: o.height}
}
if o.recordMedia {
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
Record: &appbsky.EmbedRecord_View{
Record: &appbsky.EmbedRecord_View_Record{
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
Uri: "at://did:plc:quoted/app.bsky.feed.post/q",
Cid: "bafy-quoted",
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: "did:plc:quoted",
Handle: "quoted.bsky.social",
},
IndexedAt: "2024-01-01T00:00:00Z",
},
},
},
Media: &appbsky.EmbedRecordWithMedia_View_Media{
EmbedVideo_View: v,
},
},
}
} else {
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedVideo_View: v,
}
}
}
}
func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
playlist := "https://video.bsky.app/v/did:plc:alice/v/playlist.m3u8"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch this",
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "A trip to the park",
hasAspect: true, width: 16, height: 9,
}))
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing from mainEntity")
}
if video["@type"] != "VideoObject" {
t.Errorf("@type = %v, want VideoObject", video["@type"])
}
if video["name"] != "A trip to the park" {
t.Errorf("name = %v, want alt text", video["name"])
}
if video["description"] != "watch this" {
t.Errorf("description = %v, want post text", video["description"])
}
if video["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb)
}
if video["uploadDate"] != pv.IndexedAt {
t.Errorf("uploadDate = %v, want %v", video["uploadDate"], pv.IndexedAt)
}
if video["contentUrl"] != playlist {
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
}
if video["embedUrl"] != canonical {
t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical)
}
if w, _ := video["width"].(float64); int64(w) != 16 {
t.Errorf("width = %v, want 16", video["width"])
}
if h, _ := video["height"].(float64); int64(h) != 9 {
t.Errorf("height = %v, want 9", video["height"])
}
// post thumbnailUrl should equal the video thumb (byte-equal og:image).
if main["thumbnailUrl"] != thumb {
t.Errorf("post thumbnailUrl = %v, want %v", main["thumbnailUrl"], thumb)
}
}
func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
// No alt text -> "Video by @<handle>".
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no alt here",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing")
}
if video["name"] != "Video by @alice.bsky.social" {
t.Errorf("name fallback = %v", video["name"])
}
}
func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
// handle.invalid + no alt -> generic fallback.
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "x",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["name"] != "Video on Bluesky" {
t.Errorf("name fallback = %v, want generic", video["name"])
}
}
func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
// Empty post text -> description falls back to name so Google's video
// rich-result requirement (description present) is satisfied.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["description"] != "scenic clip" {
t.Errorf("description fallback = %v, want name", video["description"])
}
}
func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if _, present := video["width"]; present {
t.Errorf("width should be omitted when AspectRatio missing")
}
if _, present := video["height"]; present {
t.Errorf("height should be omitted when AspectRatio missing")
}
}
func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
// No playlist -> VideoObject suppressed; image[]/thumbnailUrl still set
// from the video thumb (existing behavior).
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("video without playlist should not produce VideoObject")
}
if main["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl should still be set from video thumb, got %v", main["thumbnailUrl"])
}
}
func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
// Self-labeled hide drops the video, parallel to the image-hide test.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withVideoFull(videoEmbedOpts{
thumbnail: "https://cdn.bsky.app/img/x@jpeg",
playlist: "https://video.bsky.app/p.m3u8",
alt: "should be dropped",
}),
withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("hidden-embed post should not emit video")
}
}
func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
playlist := "https://video.bsky.app/p.m3u8"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+video",
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
hasAspect: true, width: 4, height: 3,
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing on record-with-media post")
}
if video["contentUrl"] != playlist {
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
}
if video["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb)
}
// quote-post still surfaces via isBasedOn alongside the video.
if main["isBasedOn"] != "https://bsky.app/profile/quoted.bsky.social/post/q" {
t.Errorf("isBasedOn = %v", main["isBasedOn"])
}
}
func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
*pv.ReplyCount = 1
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:bob/v@jpeg"
playlist := "https://video.bsky.app/bob.m3u8"
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "watch",
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
}))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
if !ok {
t.Fatalf("reply video missing")
}
if video["@type"] != "VideoObject" {
t.Errorf("reply video @type = %v", video["@type"])
}
if video["name"] != "bob's clip" {
t.Errorf("reply video name = %v", video["name"])
}
if video["contentUrl"] != playlist {
t.Errorf("reply video contentUrl = %v", video["contentUrl"])
}
if video["embedUrl"] != "https://bsky.app/profile/bob.bsky.social/post/rep1" {
t.Errorf("reply video embedUrl = %v", video["embedUrl"])
}
}
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("post without video should not include video field")
}
}
func TestBuildProfileJSONLD_HasPartVideo(t *testing.T) {
pv := newProfileViewDetailed()
playlist := "https://video.bsky.app/p.m3u8"
post := makePostView("alice.bsky.social", "did:plc:alice", "rp1", "see",
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "alt",
}))
out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{post}, hideEmbedLabels, hideReplyLabels)
page := unmarshalLD(t, out)
hp := page["hasPart"].([]any)
if len(hp) != 1 {
t.Fatalf("expected 1 hasPart entry, got %d", len(hp))
}
video, ok := hp[0].(map[string]any)["video"].(map[string]any)
if !ok {
t.Fatalf("hasPart entry should carry video, got %v", hp[0])
}
if video["contentUrl"] != playlist {
t.Errorf("hasPart video contentUrl = %v", video["contentUrl"])
}
if video["embedUrl"] != "https://bsky.app/profile/alice.bsky.social/post/rp1" {
t.Errorf("hasPart video embedUrl = %v", video["embedUrl"])
}
}
// handle.invalid authors must still produce a non-empty embedUrl on the
// VideoObject. The handle-form URL is unusable, so we fall back to the
// DID-form URL derived from the AT-URI authority. Without this fallback,
// omitempty drops embedUrl and Google's video indexer loses the canonical
// page reference.
func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
playlist := "https://video.bsky.app/p.m3u8"
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "watch",
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "scenic clip",
}))
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing on handle.invalid post")
}
if video["embedUrl"] != canonical {
t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical)
}
if video["contentUrl"] != playlist {
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
}
}
// Same fallback applies to videos on replies whose author is handle.invalid.
func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
*pv.ReplyCount = 1
playlist := "https://video.bsky.app/bob.m3u8"
reply := makePostView("handle.invalid", "did:plc:bob", "rep1", "watch",
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "bob's clip",
}))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
if !ok {
t.Fatalf("reply video missing")
}
want := "https://bsky.app/profile/did:plc:bob/post/rep1"
if video["embedUrl"] != want {
t.Errorf("reply video embedUrl = %v, want %v", video["embedUrl"], want)
}
}
-7
View File
@@ -59,13 +59,6 @@ func run(args []string) {
Value: ":8100",
EnvVars: []string{"HTTP_ADDRESS"},
},
&cli.StringFlag{
Name: "metrics-address",
Usage: "Specify the local IP/port to bind the metrics server to",
Required: false,
Value: ":9090",
EnvVars: []string{"METRICS_HTTP_ADDRESS"},
},
&cli.StringFlag{
Name: "link-host",
Usage: "scheme, hostname, and port of link service",
-88
View File
@@ -43,13 +43,6 @@ func extractJSONLD(t *testing.T, html string) string {
return strings.TrimSpace(m[1])
}
func TestRenderBase_NoindexMeta(t *testing.T) {
html := renderTemplate(t, "base.html", pongo2.Context{"noindex": true, "nofollow": true})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("expected combined noindex,nofollow meta; got:\n%s", html)
}
}
func TestRenderPost_EmitsJSONLD(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
@@ -105,40 +98,6 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
}
}
// Gallery posts must hit the same og:image / JSON-LD image[] byte-equality
// contract that legacy images posts do. Regression guard for the
// app.bsky.embed.gallery extraction path.
func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
"imgThumbUrls": thumbs,
})
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
t.Errorf("og:image[0] not found in rendered HTML for gallery post")
}
if !strings.Contains(html, `<meta property="og:image" content="`+thumb2+`">`) {
t.Errorf("og:image[1] not found in rendered HTML for gallery post")
}
body := extractJSONLD(t, html)
var parsed map[string]any
_ = json.Unmarshal([]byte(body), &parsed)
main := parsed["mainEntity"].(map[string]any)
imgs := main["image"].([]any)
if len(imgs) != 2 || imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
t.Errorf("JSON-LD image strings drifted from og:image for gallery; image=%v thumbnailUrl=%v",
imgs, main["thumbnailUrl"])
}
}
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
@@ -250,50 +209,3 @@ func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
}
}
// Auth-required posts must emit noindex,nofollow so the stub page (no body
// text, no comments) is not indexed.
func TestRenderPost_AuthRequiredNoindex(t *testing.T) {
html := renderTemplate(t, "post.html", pongo2.Context{
"requiresAuth": true,
"profileHandle": "alice.bsky.social",
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"noindex": true,
"nofollow": true,
})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("auth-required post should emit noindex,nofollow; got:\n%s", html)
}
}
// Auth-required profiles must emit noindex,nofollow.
func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
pv := newProfileViewDetailed()
html := renderTemplate(t, "profile.html", pongo2.Context{
"profileView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social",
"requiresAuth": true,
"noindex": true,
"nofollow": true,
})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("auth-required profile should emit noindex,nofollow; got:\n%s", html)
}
}
// Public posts must NOT emit a robots meta tag. Guards against an accidental
// flip of the noindex flag for indexable pages.
func TestRenderPost_PublicNoNoindex(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
})
if strings.Contains(html, `<meta name="robots"`) {
t.Errorf("public post should not emit robots meta; got:\n%s", html)
}
}
+52 -133
View File
@@ -12,7 +12,6 @@ import (
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"net/netip"
"net/url"
@@ -36,17 +35,15 @@ import (
"github.com/labstack/echo-contrib/echoprometheus"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/urfave/cli/v2"
)
type Server struct {
echo *echo.Echo
httpd *http.Server
metricsHttpd *http.Server
xrpcc *xrpc.Client
chatXrpcc *xrpc.Client
cfg *Config
echo *echo.Echo
httpd *http.Server
xrpcc *xrpc.Client
chatXrpcc *xrpc.Client
cfg *Config
ipccClient http.Client
@@ -70,7 +67,6 @@ type Config struct {
func serve(cctx *cli.Context) error {
debug := cctx.Bool("debug")
httpAddress := cctx.String("http-address")
metricsAddress := cctx.String("metrics-address")
appviewHost := cctx.String("appview-host")
chatHost := cctx.String("chat-host")
ogcardHost := cctx.String("ogcard-host")
@@ -92,7 +88,7 @@ func serve(cctx *cli.Context) error {
Host: appviewHost,
}
// optional client for the chat appview, used by /chat/<code> for OG previews.
// optional client for the chat appview, used by /c/<code> for OG previews.
var chatXrpcc *xrpc.Client
if chatHost != "" {
chatXrpcc = &xrpc.Client{
@@ -300,50 +296,50 @@ func serve(cctx *cli.Context) error {
// generic routes
e.GET("/hashtag/:tag", server.WebGeneric)
e.GET("/topic/:topic", server.WebGeneric)
e.GET("/search", server.WebGenericNoindex)
e.GET("/feeds", server.WebGenericNoindex)
e.GET("/notifications", server.WebGenericNoindex)
e.GET("/notifications/settings", server.WebGenericNoindex)
e.GET("/notifications/activity", server.WebGenericNoindex)
e.GET("/lists", server.WebGenericNoindex)
e.GET("/moderation", server.WebGenericNoindex)
e.GET("/moderation/modlists", server.WebGenericNoindex)
e.GET("/moderation/muted-accounts", server.WebGenericNoindex)
e.GET("/moderation/blocked-accounts", server.WebGenericNoindex)
e.GET("/moderation/verification-settings", server.WebGenericNoindex)
e.GET("/settings", server.WebGenericNoindex)
e.GET("/settings/language", server.WebGenericNoindex)
e.GET("/settings/app-passwords", server.WebGenericNoindex)
e.GET("/settings/following-feed", server.WebGenericNoindex)
e.GET("/settings/saved-feeds", server.WebGenericNoindex)
e.GET("/settings/threads", server.WebGenericNoindex)
e.GET("/settings/external-embeds", server.WebGenericNoindex)
e.GET("/settings/accessibility", server.WebGenericNoindex)
e.GET("/settings/appearance", server.WebGenericNoindex)
e.GET("/settings/account", server.WebGenericNoindex)
e.GET("/settings/automation-label", server.WebGenericNoindex)
e.GET("/settings/privacy-and-security", server.WebGenericNoindex)
e.GET("/settings/privacy-and-security/activity", server.WebGenericNoindex)
e.GET("/settings/content-and-media", server.WebGenericNoindex)
e.GET("/settings/interests", server.WebGenericNoindex)
e.GET("/settings/about", server.WebGenericNoindex)
e.GET("/settings/notifications", server.WebGenericNoindex)
e.GET("/sys/debug", server.WebGenericNoindex)
e.GET("/sys/debug-mod", server.WebGenericNoindex)
e.GET("/sys/log", server.WebGenericNoindex)
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
e.GET("/notifications/activity", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
e.GET("/moderation/muted-accounts", server.WebGeneric)
e.GET("/moderation/blocked-accounts", server.WebGeneric)
e.GET("/moderation/verification-settings", server.WebGeneric)
e.GET("/settings", server.WebGeneric)
e.GET("/settings/language", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric)
e.GET("/settings/following-feed", server.WebGeneric)
e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/settings/accessibility", server.WebGeneric)
e.GET("/settings/appearance", server.WebGeneric)
e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/automation-label", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric)
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric)
e.GET("/settings/interests", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric)
e.GET("/settings/notifications", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
e.GET("/support", server.WebGeneric)
e.GET("/support/privacy", server.WebGeneric)
e.GET("/support/tos", server.WebGeneric)
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGenericNoindexNofollow)
e.GET("/intent/verify-email", server.WebGenericNoindexNofollow)
e.GET("/intent/age-assurance", server.WebGenericNoindexNofollow)
e.GET("/messages", server.WebGenericNoindex)
e.GET("/messages/inbox", server.WebGenericNoindex)
e.GET("/messages/:conversation", server.WebGenericNoindex)
e.GET("/messages/:conversation/settings", server.WebGenericNoindex)
e.GET("/messages/:conversation/requests", server.WebGenericNoindex)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/intent/verify-email", server.WebGeneric)
e.GET("/intent/age-assurance", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/inbox", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
e.GET("/messages/:conversation/settings", server.WebGeneric)
e.GET("/messages/:conversation/requests", server.WebGeneric)
// profile endpoints; only first populates info
e.GET("/profile/:handleOrDID", server.WebProfile)
@@ -367,14 +363,14 @@ func serve(cctx *cli.Context) error {
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/starter-pack-short/:code", server.WebGenericNoindex)
e.GET("/starter-pack-short/:code", server.WebGeneric)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// chat invites
e.GET("/chat/:code", server.WebChatInvite)
e.GET("/c/:code", server.WebChatInvite)
// bookmarks
e.GET("/saved", server.WebGenericNoindex)
e.GET("/saved", server.WebGeneric)
// ipcc
e.GET("/ipcc", server.WebIpCC)
@@ -391,19 +387,6 @@ func serve(cctx *cli.Context) error {
e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl))
}
metricsHttpd, metricsListener, err := newMetricsHTTPServer(metricsAddress)
if err != nil {
return err
}
server.metricsHttpd = metricsHttpd
log.Infof("starting metrics server address=%s", metricsAddress)
go func() {
if err := metricsHttpd.Serve(metricsListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Errorf("metrics HTTP server shutting down unexpectedly: %s", err)
}
}()
// Start the server.
log.Infof("starting server address=%s", httpAddress)
go func() {
@@ -436,24 +419,6 @@ func serve(cctx *cli.Context) error {
return nil
}
func newMetricsHTTPServer(address string) (*http.Server, net.Listener, error) {
metricsMux := http.NewServeMux()
metricsMux.Handle("/metrics", promhttp.Handler())
metricsHttpd := &http.Server{
Addr: address,
Handler: metricsMux,
ReadHeaderTimeout: 5 * time.Second,
}
metricsListener, err := net.Listen("tcp", address)
if err != nil {
return nil, nil, fmt.Errorf("listen metrics address %s: %w", address, err)
}
return metricsHttpd, metricsListener, nil
}
func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
srv.echo.ServeHTTP(rw, req)
}
@@ -464,18 +429,7 @@ func (srv *Server) Shutdown() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var shutdownErr error
if srv.metricsHttpd != nil {
if err := srv.metricsHttpd.Shutdown(ctx); err != nil {
shutdownErr = fmt.Errorf("metrics HTTP server shutdown error: %w", err)
}
}
if err := srv.httpd.Shutdown(ctx); err != nil {
return errors.Join(shutdownErr, err)
}
return shutdownErr
return srv.httpd.Shutdown(ctx)
}
// NewTemplateContext returns a new pongo2 context with some default values.
@@ -483,8 +437,6 @@ func (srv *Server) NewTemplateContext() pongo2.Context {
return pongo2.Context{
"staticCDNHost": srv.cfg.staticCDNHost,
"favicon": fmt.Sprintf("%s/static/favicon.png", srv.cfg.staticCDNHost),
"noindex": false,
"nofollow": false,
}
}
@@ -537,38 +489,10 @@ func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
)
}
// renderOptions controls per-request rendering flags for the generic web handler.
type renderOptions struct {
noindex bool
nofollow bool
}
// webGeneric returns a handler that renders the base SPA shell with the given
// render options applied to the template context.
func (srv *Server) webGeneric(c echo.Context, o renderOptions) error {
data := srv.NewTemplateContext()
data["noindex"] = o.noindex
data["nofollow"] = o.nofollow
return c.Render(http.StatusOK, "base.html", data)
}
// handler for endpoint that have no specific server-side handling
func (srv *Server) WebGeneric(c echo.Context) error {
return srv.webGeneric(c, renderOptions{})
}
// handler for routes that should not be indexed by search engines
// (e.g. auth-only user-state surfaces, internal/debug pages, action/intent dispatch URLs, search results)
func (srv *Server) WebGenericNoindex(c echo.Context) error {
return srv.webGeneric(c, renderOptions{noindex: true})
}
// handler for action/intent dispatch URLs (e.g. /intent/compose). These accept
// arbitrary query parameters from arbitrary third-party referrers, so we treat
// them as link-graph dead-ends in addition to noindex. Anything legitimately
// reachable from a hydrated intent page is also reachable via its canonical URL.
func (srv *Server) WebGenericNoindexNofollow(c echo.Context) error {
return srv.webGeneric(c, renderOptions{noindex: true, nofollow: true})
data := srv.NewTemplateContext()
return c.Render(http.StatusOK, "base.html", data)
}
func (srv *Server) WebHome(c echo.Context) error {
@@ -643,8 +567,6 @@ func (srv *Server) WebPost(c echo.Context) error {
data["canonicalURL"] = canonicalURL
}
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
data["profileHandle"] = pv.Handle
if pv.DisplayName != nil {
data["profileDisplayName"] = *pv.DisplayName
@@ -751,7 +673,6 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
req := c.Request()
ctx := req.Context()
data := srv.NewTemplateContext()
data["noindex"] = true
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
code := c.Param("code")
@@ -774,7 +695,7 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
data["title"] = preview.Name
if srv.cfg.ogcardHost != "" {
// bskyogcard registers this route as /chat-invite/:code, not /chat/:code.
// bskyogcard registers this route as /chat-invite/:code, not /c/:code.
data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code)
}
return c.Render(http.StatusOK, "chatinvite.html", data)
@@ -839,8 +760,6 @@ func (srv *Server) WebProfile(c echo.Context) error {
}
} else {
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
}
if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels, hideReplyLabels); err == nil {
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
go 1.26
require (
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0
github.com/flosch/pongo2/v6 v6.0.0
github.com/ipfs/go-log v1.0.5
github.com/joho/godotenv v1.5.1
+2 -2
View File
@@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY=
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-1
View File
@@ -139,7 +139,6 @@
<meta name="theme-color">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
{% if noindex %}<meta name="robots" content="noindex{% if nofollow %}, nofollow{% endif %}">{% endif %}
<meta property="og:site_name" content="Bluesky Social">
<meta property="og:logo" content="{{ favicon }}">
<meta name="twitter:site" content="@bluesky" />
+80 -109
View File
@@ -1,113 +1,4 @@
{
"modules/bottom-sheet/src/BottomSheetNativeComponent.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-call": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"modules/bottom-sheet/src/BottomSheetPortal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"modules/bottom-sheet/src/lib/Portal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts": {
"@typescript-eslint/require-await": {
"count": 4
}
},
"modules/expo-bluesky-gif-view/src/GifView.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-call": {
"count": 4
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 4
},
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-bluesky-gif-view/src/GifView.web.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
},
"@typescript-eslint/require-await": {
"count": 2
}
},
"modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts": {
"@typescript-eslint/no-unsafe-call": {
"count": 3
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 3
}
},
"modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts": {
"@typescript-eslint/no-unsafe-call": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"modules/expo-bluesky-swiss-army/src/SharedPrefs/index.native.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unsafe-call": {
"count": 9
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 9
}
},
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx": {
"@typescript-eslint/no-unsafe-call": {
"count": 1
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx": {
"@typescript-eslint/require-await": {
"count": 1
}
},
"modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts": {
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-emoji-picker/src/EmojiPickerView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/Navigation.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
@@ -128,6 +19,11 @@
"count": 1
}
},
"src/ageAssurance/util.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/alf/util/flatten.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -147,6 +43,9 @@
}
},
"src/analytics/PassiveAnalytics.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"react-hooks/purity": {
"count": 1
}
@@ -225,6 +124,11 @@
"count": 1
}
},
"src/components/Button.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"src/components/Composer/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -357,6 +261,11 @@
"count": 3
}
},
"src/components/Post/Embed/ExternalEmbed/index.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/components/Post/Embed/ImageEmbed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -365,6 +274,11 @@
"count": 1
}
},
"src/components/Post/Embed/StandardSiteEmbed/index.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 3
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
@@ -707,6 +621,11 @@
"count": 1
}
},
"src/components/dms/MessageItem.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/components/forms/DateField/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -749,6 +668,14 @@
"count": 2
}
},
"src/components/hooks/useFullscreen.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/hooks/useLandingEntry.native.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
@@ -884,6 +811,14 @@
"count": 1
}
},
"src/lib/api/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 3
}
},
"src/lib/async/retry.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -1437,6 +1372,9 @@
}
},
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 5
}
@@ -1864,6 +1802,16 @@
"count": 7
}
},
"src/state/queries/messages/accept-conversation.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
}
},
"src/state/queries/messages/update-all-read.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 3
}
},
"src/state/queries/my-lists.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
@@ -1994,6 +1942,12 @@
"src/state/session/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/state/shell/color-mode.tsx": {
@@ -2356,6 +2310,23 @@
"count": 2
}
},
"src/view/com/profile/ProfileMenu.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 6
},
"@typescript-eslint/no-floating-promises": {
"count": 4
},
"@typescript-eslint/no-misused-promises": {
"count": 3
},
"@typescript-eslint/no-unsafe-call": {
"count": 6
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 12
}
},
"src/view/com/testing/TestCtrls.e2e.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -67,18 +67,7 @@ export class GifView extends PureComponent<GifViewProps> {
}
async playAsync(): Promise<void> {
try {
await this.videoPlayerRef.current?.play()
} catch (err) {
// `play()` rejects with a NotAllowedError when the browser blocks
// playback (e.g. Safari low-power mode or autoplay policy). This is
// expected and benign - the GIF simply stays paused - so swallow it
// rather than letting it surface as an unhandled rejection.
if (err instanceof DOMException && err.name === 'NotAllowedError') {
return
}
throw err
}
this.videoPlayerRef.current?.play()
}
async pauseAsync(): Promise<void> {
@@ -1,12 +1,12 @@
import React from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import {StyleProp, ViewStyle} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {type VisibilityViewProps} from './types'
import {VisibilityViewProps} from './types'
const NativeView: React.ComponentType<{
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
children: React.ReactNode
enabled: boolean
enabled: Boolean
style: StyleProp<ViewStyle>
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
@@ -6,7 +6,6 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.core.net.toUri
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
@@ -14,8 +13,6 @@ import java.io.File
import java.io.FileOutputStream
import java.net.URLEncoder
private const val TAG = "ExpoReceiveAndroidIntents"
enum class AttachmentType {
IMAGE,
VIDEO,
@@ -122,15 +119,17 @@ class ExpoReceiveAndroidIntentsModule : Module() {
uris: List<Uri>,
text: String?,
) {
// Some URIs we receive may be unreadable (revoked permission, deleted file,
// a provider that rejects the read). Skip those rather than crashing the
// whole app, since this runs synchronously on the module init path.
val allParams =
uris
.mapNotNull { uri -> getImageInfo(uri) }
.joinToString(",") { info -> buildUriData(info) }
var allParams = ""
if (allParams.isEmpty()) return
uris.forEachIndexed { index, uri ->
val info = getImageInfo(uri)
val params = buildUriData(info)
allParams = "${allParams}$params"
if (index < uris.count() - 1) {
allParams = "$allParams,"
}
}
val encodedUris = URLEncoder.encode(allParams, "UTF-8")
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
@@ -159,30 +158,12 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
val file = createFile(extension)
// The URI may be unreadable (revoked permission, deleted file, or a
// provider that rejects the read). Bail rather than crashing the whole
// app, since this runs synchronously on the module init path.
try {
FileOutputStream(file).use { out ->
val input =
appContext.currentActivity?.contentResolver?.openInputStream(uri)
?: run {
file.delete()
return
}
input.use { it.copyTo(out) }
}
} catch (e: Exception) {
Log.w(TAG, "Failed to copy shared video to cache", e)
file.delete()
return
val out = FileOutputStream(file)
appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use {
it.copyTo(out)
}
val info =
getVideoInfo(uri) ?: run {
file.delete()
return
}
val info = getVideoInfo(uri) ?: return
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
@@ -195,29 +176,15 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
}
private fun getImageInfo(uri: Uri): Map<String, Any>? {
val bitmap =
try {
MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
} catch (e: Exception) {
// The URI may be unreadable (revoked permission, deleted file, or a
// provider that rejects the read). Skip this image rather than crash.
Log.w(TAG, "Failed to read shared image", e)
return null
} ?: return null
private fun getImageInfo(uri: Uri): Map<String, Any> {
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
// We have to save this so that we can access it later when uploading the image.
// createTempFile will automatically place a unique string between "img" and "temp.jpeg"
val file = createFile("jpeg")
try {
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to write shared image to cache", e)
file.delete()
return null
}
val out = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
out.close()
return mapOf(
"width" to bitmap.width,
@@ -228,19 +195,10 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun getVideoInfo(uri: Uri): Map<String, Any>? {
val retriever = MediaMetadataRetriever()
val width: Int?
val height: Int?
try {
retriever.setDataSource(appContext.currentActivity, uri)
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} catch (e: Exception) {
// The URI may be unreadable or not a valid media source. Skip rather than crash.
Log.w(TAG, "Failed to read shared video metadata", e)
return null
} finally {
retriever.release()
}
retriever.setDataSource(appContext.currentActivity, uri)
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
if (width == null || height == null) {
return null
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.124.0",
"version": "1.123.0",
"private": true,
"engines": {
"node": ">=24.15.0"
@@ -8,7 +8,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.5.2",
"version": "11.5.0",
"onFail": "warn"
},
"runtime": {
@@ -59,7 +59,7 @@
"test-watch": "NODE_ENV=test jest --watchAll",
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --quiet src modules",
"lint": "eslint --cache --quiet src",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsgo --project ./tsconfig.check.json",
@@ -93,17 +93,16 @@
"prettier": "prettier --check ."
},
"dependencies": {
"@atproto/api": "0.20.12",
"@atproto/api": "0.20.8",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14",
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
"@bsky.app/expo-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.3.0",
"@bsky.app/peek-menu": "^0.2.4",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.8",
"@bsky.app/tapper": "^0.5.7",
@@ -124,6 +123,7 @@
"@ipld/dag-cbor": "^9.2.7",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/native": "^7.1.33",
+66 -66
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 11.5.2
version: 11.5.2
specifier: 11.5.0
version: 11.5.0
pnpm:
specifier: 11.5.2
version: 11.5.2
specifier: 11.5.0
version: 11.5.0
packages:
'@pnpm/exe@11.5.2':
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
'@pnpm/exe@11.5.0':
resolution: {integrity: sha512-4hzOXq1HHrNPjwI8k1rt7Ot/Yrdx1JX3pn/L/M95ii1gid1Q6ZK6dVg4+gbSgUdPsYmYDZ4/Yfc0A7vd5C0ndg==}
hasBin: true
'@pnpm/linux-arm64@11.5.2':
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
'@pnpm/linux-arm64@11.5.0':
resolution: {integrity: sha512-NV9HdzzCB0epuI9LqZZeTaqjH3OweNQSQCS76GzEkFxJHS9e5Gvu7tgex91gxVL7bCZ+R4yr/3d3yexBFtr2ug==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.5.2':
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
'@pnpm/linux-x64@11.5.0':
resolution: {integrity: sha512-vH83rRx4iPk/bwm9pBVCn+5hXbcQI66I/4zk6Vc09SusJgTqOdbN4U6VhMcGIqSEdr901ksYGCyIbMv7f6Guew==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.5.2':
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
'@pnpm/linuxstatic-arm64@11.5.0':
resolution: {integrity: sha512-2nOnMW1rSwGv22q2yZz1HlGT3ly/Ij8wUlX0NB4n+Krx7nETRHA3MgWsbkVejxHknDcTulRVudAghuX9rgrXcw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.5.2':
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
'@pnpm/linuxstatic-x64@11.5.0':
resolution: {integrity: sha512-ONOC1Mg0JusHtjzkRlre9di1QO+GAjy4HP7jMjDx21yGhrSheNdUweTXbekMH1EflRd19kTU6d8M3zewJFPtVg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.5.2':
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
'@pnpm/macos-arm64@11.5.0':
resolution: {integrity: sha512-od0ALdTxs4A7s5vAH5q2l2phzCJb98+PVOW1rq7BGpWGeYxQ+EwvL+vq0KaO6iLsn/eVVoncCkgZ/k6QNYuTgw==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.5.2':
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
'@pnpm/win-arm64@11.5.0':
resolution: {integrity: sha512-9HqbI80FjVVqFx4+EPxYYNfeP9Sx69W6kYqUDvOJn9G7RJ/2NNNQ898cVHTMpXlW1/PrMEcijmdpa/NjZIrWiQ==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.5.2':
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
'@pnpm/win-x64@11.5.0':
resolution: {integrity: sha512-Q89CQqFGAsWmfvHZs5Kbbar45q3GBYtfAdPUCiVMVNJoLi3dsBS2LCvUq8ak3AufkFDaJBpvhaFcDP2M1NXr3A==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.5.2:
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
pnpm@11.5.0:
resolution: {integrity: sha512-2/zE+Bz0hZev1Lw5H/3xLBHxqfuDo5W/prCi2cwv2P/rr9scy9UpYyFT95OQTCYVt/Cf4aNFRz/Rw1hFFyqOsQ==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.5.2':
'@pnpm/exe@11.5.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.5.2
'@pnpm/linux-x64': 11.5.2
'@pnpm/linuxstatic-arm64': 11.5.2
'@pnpm/linuxstatic-x64': 11.5.2
'@pnpm/macos-arm64': 11.5.2
'@pnpm/win-arm64': 11.5.2
'@pnpm/win-x64': 11.5.2
'@pnpm/linux-arm64': 11.5.0
'@pnpm/linux-x64': 11.5.0
'@pnpm/linuxstatic-arm64': 11.5.0
'@pnpm/linuxstatic-x64': 11.5.0
'@pnpm/macos-arm64': 11.5.0
'@pnpm/win-arm64': 11.5.0
'@pnpm/win-x64': 11.5.0
'@pnpm/linux-arm64@11.5.2':
'@pnpm/linux-arm64@11.5.0':
optional: true
'@pnpm/linux-x64@11.5.2':
'@pnpm/linux-x64@11.5.0':
optional: true
'@pnpm/linuxstatic-arm64@11.5.2':
'@pnpm/linuxstatic-arm64@11.5.0':
optional: true
'@pnpm/linuxstatic-x64@11.5.2':
'@pnpm/linuxstatic-x64@11.5.0':
optional: true
'@pnpm/macos-arm64@11.5.2':
'@pnpm/macos-arm64@11.5.0':
optional: true
'@pnpm/win-arm64@11.5.2':
'@pnpm/win-arm64@11.5.0':
optional: true
'@pnpm/win-x64@11.5.2':
'@pnpm/win-x64@11.5.0':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.5.2: {}
pnpm@11.5.0: {}
---
lockfileVersion: '9.0'
@@ -242,8 +242,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.12
version: 0.20.12
specifier: 0.20.8
version: 0.20.8
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -256,9 +256,6 @@ importers:
'@bsky.app/alf':
specifier: ^0.1.14
version: 0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/expo-dynamic-app-icon':
specifier: ^1.8.5
version: 1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/expo-guess-language':
specifier: ^0.2.8
version: 0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -272,8 +269,8 @@ importers:
specifier: ^0.2.9
version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/peek-menu':
specifier: ^0.3.0
version: 0.3.0(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
specifier: ^0.2.4
version: 0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/react-native-mmkv':
specifier: 2.12.5
version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -334,6 +331,9 @@ importers:
'@lingui/react':
specifier: ^5.9.2
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
'@mozzius/expo-dynamic-app-icon':
specifier: ^1.8.0
version: 1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@react-native-async-storage/async-storage':
specifier: 2.2.0
version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
@@ -877,8 +877,8 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.12':
resolution: {integrity: sha512-pNCrl/BSmkjlrVu0W5A9zkOVRIWdWAdajuHX/EyhDZFxu8HExsiQi6j46H5kv85GrNRXS/QtULE+ocSuMtEJfw==}
'@atproto/api@0.20.8':
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -1624,13 +1624,6 @@ packages:
react: '*'
react-native: '*'
'@bsky.app/expo-dynamic-app-icon@1.8.5':
resolution: {integrity: sha512-yLpd7XEEiXWpVrh81mhpZx2WrX2WwrJFEV81lNxnX61Cp6yG+ZKX3Oz1v58vpyL5p0I38njVUJ9s+n5NR4EjNw==}
peerDependencies:
expo: ^52 || ^53 || ^54
react: '*'
react-native: '*'
'@bsky.app/expo-guess-language@0.2.8':
resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==}
peerDependencies:
@@ -1659,8 +1652,8 @@ packages:
react: '*'
react-native: '*'
'@bsky.app/peek-menu@0.3.0':
resolution: {integrity: sha512-F51CT7xawnzoIOGA86E5VNYuWCItS1DnQr/J3eRE62E5+lbuCixSK/mQ4oqVsfQbKuYfAqMsBJjCgpCkdZTqTQ==}
'@bsky.app/peek-menu@0.2.4':
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
peerDependencies:
expo: '*'
react: '*'
@@ -2334,6 +2327,13 @@ packages:
'@messageformat/parser@5.1.1':
resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==}
'@mozzius/expo-dynamic-app-icon@1.8.1':
resolution: {integrity: sha512-JWNY9gw06s+q54b2SqWf6BEo7IYuJCtjJDtca+wTo6kP5dH/wYK85JdLrMbdy+cwOMScEY85uU6Mjn0wkWTLnw==}
peerDependencies:
expo: ^52 || ^53 || ^54
react: '*'
react-native: '*'
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -9493,7 +9493,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.12':
'@atproto/api@0.20.8':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
@@ -10446,14 +10446,6 @@ snapshots:
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
react-responsive: 10.0.1(react@19.1.0)
'@bsky.app/expo-dynamic-app-icon@1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
'@expo/image-utils': 0.8.12
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
xcode: 3.0.1
'@bsky.app/expo-guess-language@0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -10479,7 +10471,7 @@ snapshots:
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
'@bsky.app/peek-menu@0.3.0(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
'@bsky.app/peek-menu@0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
@@ -11457,6 +11449,14 @@ snapshots:
dependencies:
moo: 0.5.3
'@mozzius/expo-dynamic-app-icon@1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
'@expo/image-utils': 0.8.12
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
xcode: 3.0.1
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.10.0
+1 -17
View File
@@ -134,7 +134,6 @@ import {
import {useAnalytics} from '#/analytics'
import {setNavigationMetadata} from '#/analytics/metadata'
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {InviteScannerScreen} from '#/features/inviteFriends'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
@@ -310,11 +309,6 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => DebugModScreen}
options={{title: title(msg`Moderation states`), requireAuth: true}}
/>
<Stack.Screen
name="InviteScanner"
getComponent={() => InviteScannerScreen}
options={{title: title(msg`Scan QR code`), requireAuth: true}}
/>
<Stack.Screen
name="SharedPreferencesTester"
getComponent={() => SharedPreferencesTesterScreen}
@@ -810,7 +804,7 @@ const LINKING = {
return buildStateObject('Flat', 'Home', params)
}
// Chat invite URLs (`/chat/:code`) are handled by `useIntentHandler`, which
// Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
// path to Home so the dialog overlays Home instead of NotFound. On native,
// react-navigation strips the `bluesky://` prefix and passes the path
@@ -850,8 +844,6 @@ const LINKING = {
},
} satisfies LinkingOptions<AllNavigatorParams>
let didHandlePushNotificationEntry = false
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const ax = useAnalytics()
// eslint-disable-next-line react-compiler/react-compiler
@@ -912,14 +904,6 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
function handlePushNotificationEntry() {
if (!IS_NATIVE) return
// Only consume a launching notification once per JS runtime. Account
// switches remount the entire tree (see `key={currentAccount?.did}` in
// `App.native.tsx`), which re-fires `onNavigationReady` and would
// otherwise re-process whatever `getLastNotificationResponse` still has
// cached natively (APP-2338).
if (didHandlePushNotificationEntry) return
didHandlePushNotificationEntry = true
// intent urls are handled by `useIntentHandler`
if (linkingUrl) return
+1 -1
View File
@@ -1,3 +1,3 @@
export const prefetchAgeAssuranceServerData = () => {}
export const prefetchAgeAssuranceData = () => {}
export const setBirthdateForDid = () => {}
export const setCreatedAtForDid = () => {}
@@ -32,7 +32,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {
isLegacyBirthdateBug,
@@ -53,7 +53,7 @@ export function NoAccessScreen() {
const birthdateControl = useDialogControl()
const deactivateAccountControl = useDialogControl()
const deleteAccountControl = useDialogControl()
const {metadata} = useAgeAssuranceServerDataContext()
const {data} = useAgeAssuranceDataContext()
const region = useAgeAssuranceRegionConfig()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
const {logoutCurrentAccount} = useSessionApi()
@@ -62,15 +62,15 @@ export function NoAccessScreen() {
const aa = useAgeAssurance()
const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region
const hasDeclaredAge = metadata?.declaredAge !== undefined
const hasDeclaredAge = data?.declaredAge !== undefined
const canUpdateBirthday =
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
useEffect(() => {
// just counting overall hits here
ax.metric(`blockedGeoOverlay:shown`, {})
ax.metric(`ageAssurance:noAccessScreen:shown`, {
accountCreatedAt: metadata?.accountCreatedAt || 'unknown',
accountCreatedAt: data?.accountCreatedAt || 'unknown',
isAARegion,
hasDeclaredAge,
canUpdateBirthday,
-28
View File
@@ -1,28 +0,0 @@
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
} from '@atproto/api'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
/**
* Minimum age required to access the app at all.
*/
export const MIN_ACCESS_AGE = 13
export const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
countryCode: '*',
regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
+22 -24
View File
@@ -24,7 +24,6 @@ import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declar
import {useAgent, useSession} from '#/state/session'
import * as debug from '#/ageAssurance/debug'
import {logger} from '#/ageAssurance/logger'
import {type AgeAssuranceMetadata} from '#/ageAssurance/types'
import {
getBirthdateStringFromAge,
isLegacyBirthdateBug,
@@ -486,9 +485,9 @@ export function useOtherRequiredDataQuery() {
}
/**
* Helper to prefetch all age assurance data from the server.
* Helper to prefetch all age assurance data.
*/
export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
return Promise.allSettled([
// config fetch initiated at the top of the App.platform.tsx files, awaited here
configPrefetchPromise,
@@ -497,8 +496,8 @@ export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
])
}
export function clearAgeAssuranceServerDataForDid({did}: {did: string}) {
logger.debug(`clearAgeAssuranceServerDataForDid: ${did}`)
export function clearAgeAssuranceDataForDid({did}: {did: string}) {
logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
qc.removeQueries({
queryKey: createOtherRequiredDataQueryKey({did}),
@@ -506,8 +505,8 @@ export function clearAgeAssuranceServerDataForDid({did}: {did: string}) {
})
}
export function clearAgeAssuranceServerDataForAll() {
logger.debug(`clearAgeAssuranceServerDataForAll`)
export function clearAgeAssuranceData() {
logger.debug(`clearAgeAssuranceData`)
qc.clear()
}
@@ -515,30 +514,30 @@ export function clearAgeAssuranceServerDataForAll() {
* Context
*/
export type AgeAssuranceServerData = {
/**
* The raw config from the appview.
*/
export type AgeAssuranceData = {
config: AppBskyAgeassuranceDefs.Config | undefined
/**
* The raw state from the appview. Must be further processed before being useful.
*/
state: AppBskyAgeassuranceDefs.State | undefined
metadata: AgeAssuranceMetadata | undefined
data:
| {
accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
declaredAge: number | undefined
birthdate: string | undefined
}
| undefined
}
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
config: undefined,
state: undefined,
metadata: {
data: {
accountCreatedAt: undefined,
declaredAge: undefined,
birthdate: undefined,
},
})
export function useAgeAssuranceServerDataContext() {
return useContext(AgeAssuranceServerDataContext)
export function useAgeAssuranceDataContext() {
return useContext(AgeAssuranceDataContext)
}
export function AgeAssuranceServerDataProvider({
export function AgeAssuranceDataProvider({
children,
}: {
children: React.ReactNode
@@ -551,8 +550,7 @@ export function AgeAssuranceServerDataProvider({
() => ({
config,
state,
metadata: {
// yes, it's weird, but accountCreatedAt comes back on the `getState` endpoint
data: {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: data?.birthdate
? getAge(new Date(data.birthdate))
@@ -563,8 +561,8 @@ export function AgeAssuranceServerDataProvider({
[config, state, data, metadata],
)
return (
<AgeAssuranceServerDataContext.Provider value={ctx}>
<AgeAssuranceDataContext.Provider value={ctx}>
{children}
</AgeAssuranceServerDataContext.Provider>
</AgeAssuranceDataContext.Provider>
)
}
+28 -213
View File
@@ -26,8 +26,35 @@ export const deviceGeolocation: Geolocation | undefined =
}
: undefined
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
minAccessAge: 13,
rules: [
{
$type: ids.Default,
access: 'full',
},
],
},
{
countryCode: 'BB',
regionCode: undefined,
minAccessAge: 16,
rules: [
{
$type: ids.Default,
access: 'full',
},
],
},
],
}
export const otherRequiredData: OtherRequiredData = {
birthdate: new Date(2010, 12, 1).toISOString(),
birthdate: new Date(2000, 1, 1).toISOString(),
}
const serverStateEnabled = false || IS_E2E
@@ -45,218 +72,6 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
}
: undefined
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
minAccessAge: 13,
rules: [
{
$type: ids.Default,
access: 'full',
},
],
},
{
countryCode: 'GB',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'AU',
minAccessAge: 16,
rules: [
{
date: '2025-12-10T00:00:00Z',
access: 'none',
$type: ids.IfAccountNewerThan,
},
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 16,
access: 'safe',
$type: ids.IfAssuredOverAge,
},
{
age: 16,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'SD',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'WY',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'OH',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'MS',
minAccessAge: 18,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'VA',
minAccessAge: 16,
rules: [
{
age: 16,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 16,
access: 'full',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'TN',
minAccessAge: 18,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 18,
access: 'full',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'BR',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 18,
access: 'full',
$type: ids.IfDeclaredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
],
}
export async function resolve<T>(data: T) {
await new Promise(y => setTimeout(y, 500)) // simulate network
return data
+49 -40
View File
@@ -1,12 +1,11 @@
import {createContext, useCallback, useContext, useMemo} from 'react'
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {useAgent} from '#/state/session'
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
import {
AgeAssuranceServerDataProvider,
useAgeAssuranceServerDataContext,
AgeAssuranceDataProvider,
useAgeAssuranceDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
@@ -15,29 +14,37 @@ import {
} from '#/ageAssurance/state'
import {
AgeAssuranceAccess,
type AgeAssuranceFlags,
type AgeAssuranceState,
AgeAssuranceStatus,
} from '#/ageAssurance/types'
import {
computeAgeAssuranceFlags,
isUnderAge,
maybeRestrictChatSettings,
MIN_ACCESS_AGE,
useAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
export {
prefetchConfig as prefetchAgeAssuranceConfig,
prefetchAgeAssuranceServerData,
prefetchAgeAssuranceData,
refetchServerState as refetchAgeAssuranceServerState,
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
usePatchServerState as usePatchAgeAssuranceServerState,
} from '#/ageAssurance/data'
export {logger} from '#/ageAssurance/logger'
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
const AgeAssuranceStateContext = createContext<{
Access: typeof AgeAssuranceAccess
Status: typeof AgeAssuranceStatus
state: AgeAssuranceState
flags: AgeAssuranceFlags
flags: {
adultContentDisabled: boolean
chatDisabled: boolean
isDeclaredUnderAdultAge: boolean
isOverRegionMinAccessAge: boolean
isOverAppMinAccessAge: boolean
}
}>({
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
@@ -47,10 +54,8 @@ const AgeAssuranceStateContext = createContext<{
access: AgeAssuranceAccess.Full,
},
flags: {
isAgeRestricted: false,
adultContentDisabled: false,
chatDisabled: false,
groupChatDisabled: false,
isDeclaredUnderAdultAge: false,
isOverRegionMinAccessAge: false,
isOverAppMinAccessAge: false,
@@ -68,61 +73,65 @@ export function useAgeAssurance() {
export function Provider({children}: {children: React.ReactNode}) {
return (
<AgeAssuranceServerDataProvider>
<AgeAssuranceDataProvider>
<InnerProvider>
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
</InnerProvider>
</AgeAssuranceServerDataProvider>
</AgeAssuranceDataProvider>
)
}
function InnerProvider({children}: {children: React.ReactNode}) {
const agent = useAgent()
const state = useAgeAssuranceState()
const {metadata} = useAgeAssuranceServerDataContext()
const regionConfig = useAgeAssuranceRegionConfigWithFallback()
const {data} = useAgeAssuranceDataContext()
const config = useAgeAssuranceRegionConfigWithFallback()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const handleAccessUpdate = useCallback(
(s: AgeAssuranceState) => {
const flags = computeAgeAssuranceFlags({
state: s,
regionConfig,
metadata,
})
if (flags.isAgeRestricted) {
void getAndRegisterPushToken({
isAgeRestricted: true,
})
}
if (flags.chatDisabled || flags.groupChatDisabled) {
void restrictChatSettings({
agent,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
const isAgeRestricted = s.access !== AgeAssuranceAccess.Full
if (isAgeRestricted) {
void getAndRegisterPushToken({isAgeRestricted})
maybeRestrictChatSettings({agent})
}
},
[agent, getAndRegisterPushToken, regionConfig, metadata],
[agent, getAndRegisterPushToken],
)
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
useEffect(() => {
logger.debug(`useAgeAssuranceState`, {state})
}, [state])
return (
<AgeAssuranceStateContext.Provider
value={useMemo(() => {
const res = {
const chatDisabled = state.access !== AgeAssuranceAccess.Full
const isDeclaredUnderAdultAge = data?.birthdate
? isUnderAge(data.birthdate, 18)
: true
const isOverRegionMinAccessAge = data?.birthdate
? !isUnderAge(data.birthdate, config.minAccessAge)
: false
const isOverAppMinAccessAge = data?.birthdate
? !isUnderAge(data.birthdate, MIN_ACCESS_AGE)
: false
const adultContentDisabled =
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
return {
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
state,
flags: computeAgeAssuranceFlags({
state,
regionConfig,
metadata,
}),
flags: {
adultContentDisabled,
chatDisabled,
isDeclaredUnderAdultAge,
isOverRegionMinAccessAge,
isOverAppMinAccessAge,
},
}
logger.debug(`useAgeAssurance`, res)
return res
}, [state, metadata, regionConfig])}>
}, [state, data, config])}>
{children}
</AgeAssuranceStateContext.Provider>
)
+29 -51
View File
@@ -1,30 +1,24 @@
import {useEffect, useMemo, useState} from 'react'
import {
type AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess,
} from '@atproto/api'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session'
import {
type AgeAssuranceData,
getConfigFromCache,
getOtherRequiredDataFromCache,
getServerStateFromCache,
useAgeAssuranceServerDataContext,
useAgeAssuranceDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
AgeAssuranceAccess,
type AgeAssuranceMetadata,
type AgeAssuranceState,
AgeAssuranceStatus,
parseAccessFromString,
parseStatusFromString,
} from '#/ageAssurance/types'
import {
computeAgeAssuranceFlags,
getAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
import {type Geolocation, useGeolocation} from '#/geolocation'
import {device} from '#/storage'
@@ -33,18 +27,18 @@ import {device} from '#/storage'
* server state before computing access based on AA config from the server +
* geolocation and other data.
*/
function computeAgeAssuranceState({
export function computeAgeAssuranceState({
hasSession,
geolocation,
config,
geolocation,
state,
metadata,
data,
}: {
hasSession: boolean
config: AgeAssuranceData['config']
geolocation: Geolocation
config?: AppBskyAgeassuranceDefs.Config
state?: AppBskyAgeassuranceDefs.State
metadata?: AgeAssuranceMetadata
state: AgeAssuranceData['state']
data: AgeAssuranceData['data']
}) {
/**
* This is where we control logged-out moderation prefs. It's all
@@ -94,10 +88,7 @@ function computeAgeAssuranceState({
* accounts with an accurate birthdate, our default fallback rules should
* ensure correct access.
*/
const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge,
})
const result = computeAgeAssuranceRegionAccess(region, data)
const computed = {
lastInitiatedAt: state?.lastInitiatedAt,
// prefer server state
@@ -109,10 +100,10 @@ function computeAgeAssuranceState({
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full,
}
logger.debug('computeAgeAssuranceState', {
logger.debug('debug useAgeAssuranceState', {
region,
state,
metadata,
data,
computed,
})
return computed
@@ -122,51 +113,38 @@ function computeAgeAssuranceState({
* This is a last-ditch helper for out-of-band reads of the AA state, such as
* during account creation. Don't use it for anything else.
*/
export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
export function getAndComputeAgeAssuranceState({did}: {did: string}) {
const config = getConfigFromCache()
const state = getServerStateFromCache({did})
const requiredData = getOtherRequiredDataFromCache({did})
const data = getOtherRequiredDataFromCache({did})
const geolocation = device.get(['mergedGeolocation'])
if (!geolocation || !config || !state || !requiredData) {
if (!geolocation || !config || !state || !data) {
return {
state: {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
},
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
}
}
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
const metadata: AgeAssuranceMetadata = {
accountCreatedAt: state.metadata?.accountCreatedAt,
declaredAge: requiredData?.birthdate
? getAge(new Date(requiredData.birthdate))
: undefined,
birthdate: requiredData?.birthdate,
}
const computed = computeAgeAssuranceState({
return computeAgeAssuranceState({
hasSession: true,
config,
geolocation,
state: state.state,
metadata,
data: {
accountCreatedAt: state.metadata?.accountCreatedAt,
declaredAge: data?.birthdate
? getAge(new Date(data.birthdate))
: undefined,
birthdate: data?.birthdate,
},
})
return {
state: computed,
flags: computeAgeAssuranceFlags({
state: computed,
regionConfig: region,
metadata,
}),
}
}
export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession()
const geolocation = useGeolocation()
const {config, state, metadata} = useAgeAssuranceServerDataContext()
const {config, state, data} = useAgeAssuranceDataContext()
return useMemo(
() =>
@@ -175,9 +153,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
config,
geolocation,
state,
metadata,
data,
}),
[hasSession, geolocation, config, state, metadata],
[hasSession, geolocation, config, state, data],
)
}
-18
View File
@@ -1,5 +1,3 @@
import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
import {logger} from '#/ageAssurance/logger'
export enum AgeAssuranceAccess {
@@ -16,12 +14,6 @@ export enum AgeAssuranceStatus {
Blocked = 'blocked',
}
export type AgeAssuranceMetadata = Parameters<
typeof computeAgeAssuranceRegionAccess
>[1] & {
birthdate: string | undefined
}
export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
@@ -29,16 +21,6 @@ export type AgeAssuranceState = {
error?: 'config' // maybe other specific cases in the future
}
export type AgeAssuranceFlags = {
isAgeRestricted: boolean
adultContentDisabled: boolean
chatDisabled: boolean
groupChatDisabled: boolean
isDeclaredUnderAdultAge: boolean
isOverRegionMinAccessAge: boolean
isOverAppMinAccessAge: boolean
}
export function parseStatusFromString(raw: string) {
switch (raw) {
case 'unknown':
@@ -1,14 +1,14 @@
import {useCallback} from 'react'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
import {type Geolocation} from '#/geolocation'
export function useComputeAgeAssuranceRegionAccess() {
const {config, metadata} = useAgeAssuranceServerDataContext()
const {config, data} = useAgeAssuranceDataContext()
return useCallback(
(geolocation: Geolocation) => {
if (!config) {
@@ -19,14 +19,11 @@ export function useComputeAgeAssuranceRegionAccess() {
config,
geolocation,
)
const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge,
})
const result = computeAgeAssuranceRegionAccess(region, data)
return result
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full
},
[config, metadata],
[config, data],
)
}
+39 -42
View File
@@ -1,22 +1,41 @@
import {useMemo} from 'react'
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
type AtpAgent,
getAgeAssuranceRegionConfig,
type ModerationPrefs,
} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {
AgeAssuranceAccess,
type AgeAssuranceFlags,
type AgeAssuranceMetadata,
type AgeAssuranceState,
} from '#/ageAssurance/types'
getDidFromAgentSession,
getOtherRequiredDataFromCache,
useAgeAssuranceDataContext,
} from '#/ageAssurance/data'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
export const MIN_ACCESS_AGE = 13
const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
countryCode: '*',
regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
/**
* Get age assurance region config based on geolocation, with fallback to
* app defaults if no region config is found.
@@ -43,7 +62,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
*/
export function useAgeAssuranceRegionConfig() {
const geolocation = useGeolocation()
const {config} = useAgeAssuranceServerDataContext()
const {config} = useAgeAssuranceDataContext()
return useMemo(() => {
if (!config) return
// use generic helper, we want to potentially return undefined
@@ -97,37 +116,15 @@ export const makeAgeRestrictedModerationPrefs = (
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
})
export function computeAgeAssuranceFlags({
state,
regionConfig,
metadata,
}: {
state: AgeAssuranceState
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
metadata?: AgeAssuranceMetadata
}): AgeAssuranceFlags {
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
const chatDisabled = isAgeRestricted
const isDeclaredUnderAdultAge = metadata?.declaredAge
? metadata.declaredAge < 18
: true
const groupChatDisabled = chatDisabled || isDeclaredUnderAdultAge
const isOverRegionMinAccessAge = metadata?.declaredAge
? metadata.declaredAge >= regionConfig.minAccessAge
: false
const isOverAppMinAccessAge = metadata?.declaredAge
? metadata.declaredAge >= MIN_ACCESS_AGE
: false
const adultContentDisabled =
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
return {
isAgeRestricted,
adultContentDisabled,
chatDisabled,
groupChatDisabled,
isDeclaredUnderAdultAge,
isOverRegionMinAccessAge,
isOverAppMinAccessAge,
}
/**
* Checks our cache of the actor's chat declaration record, and if it's not
* already restricted, restricts it.
*/
export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
const data = getOtherRequiredDataFromCache({did})
// ...update the chat setting record if allowIncoming is not already 'none'.
if (data?.actorDeclaration?.allowIncoming === 'none') return
restrictChatSettings({agent, did})
}
+1 -7
View File
@@ -9,12 +9,7 @@ import {
setFontScale as persistFontScale,
} from '#/alf/fonts'
import {themes} from '#/alf/themes'
import {
contrastRatio,
darken,
lighten,
rgbToHex,
} from '#/alf/util/colorGeneration'
import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration'
import {type Device} from '#/storage'
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
@@ -31,7 +26,6 @@ export const utils = {
rgbToHex,
lighten,
darken,
contrastRatio,
}
export type Alf = {
+1 -37
View File
@@ -1,10 +1,4 @@
import {
contrastRatio,
darken,
hexToRgb,
lighten,
rgbToHex,
} from './colorGeneration'
import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration'
describe('hexToRgb', () => {
it('parses 6-digit hex', () => {
@@ -98,33 +92,3 @@ describe('lighten / darken', () => {
expect(darken('#zzz', 10)).toBe('#zzz')
})
})
describe('contrastRatio', () => {
it('returns 21 for black on white', () => {
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5)
})
it('returns 1 for identical colors', () => {
expect(contrastRatio('#abcdef', '#abcdef')).toBeCloseTo(1, 5)
})
it('is symmetric regardless of argument order', () => {
expect(contrastRatio('#123456', '#fedcba')).toBeCloseTo(
contrastRatio('#fedcba', '#123456')!,
5,
)
})
it('clears AAA large text (4.5:1) for a high-contrast pairing', () => {
expect(contrastRatio('#1d3a5f', '#ffffff')!).toBeGreaterThanOrEqual(4.5)
})
it('fails AAA large text (4.5:1) for a low-contrast pairing', () => {
expect(contrastRatio('#777777', '#888888')!).toBeLessThan(4.5)
})
it('returns null for invalid hex input', () => {
expect(contrastRatio('not-a-color', '#ffffff')).toBeNull()
expect(contrastRatio('#ffffff', '#zzz')).toBeNull()
})
})
-42
View File
@@ -72,48 +72,6 @@ export function rgbToHex(r: number, g: number, b: number): string {
.slice(1)}`
}
/**
* Computes the WCAG contrast ratio between two colors, ranging from 1 (no
* contrast) to 21 (maximum contrast, i.e. black on white). Returns null if
* either argument is not a valid hex color.
*
* @see https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
*/
export function contrastRatio(hexA: string, hexB: string): number | null {
const rgbA = hexToRgb(hexA)
const rgbB = hexToRgb(hexB)
if (!rgbA || !rgbB) return null
const luminanceA = relativeLuminance(rgbA)
const luminanceB = relativeLuminance(rgbB)
const lighter = Math.max(luminanceA, luminanceB)
const darker = Math.min(luminanceA, luminanceB)
return (lighter + 0.05) / (darker + 0.05)
}
/**
* Computes the WCAG relative luminance of an RGB color, ranging from 0 (black)
* to 1 (white).
*
* @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
*/
function relativeLuminance({
r,
g,
b,
}: {
r: number
g: number
b: number
}): number {
const toLinear = (channel: number) => {
const normalized = channel / 255
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4
}
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
}
function rgbToHsl(
r: number,
g: number,
-19
View File
@@ -1,19 +0,0 @@
import {useEffect, useState} from 'react'
import {Dimensions} from 'react-native'
/**
* Same as `useWindowDimensions().fontScale`, but avoids rerendering
* whenever the screen size changes
*/
export function useNativeFontScale() {
const [fontScale, setFontScale] = useState(Dimensions.get('window').fontScale)
useEffect(() => {
const sub = Dimensions.addEventListener('change', evt => {
setFontScale(evt.window.fontScale)
})
return () => sub.remove()
}, [])
return fontScale
}
+15 -13
View File
@@ -2,6 +2,8 @@ import {useEffect, useRef} from 'react'
import {getCurrentState, onAppStateChange} from '#/lib/appState'
import {useAnalytics} from '#/analytics'
import {Features, features} from '#/analytics/features'
import {IS_DEV, IS_TESTFLIGHT} from '#/env'
/**
* Tracks passive analytics like app foreground/background time.
@@ -25,19 +27,19 @@ export function PassiveAnalytics() {
})
}
// if (IS_DEV || IS_TESTFLIGHT) {
// const feats = Object.values(Features).reduce(
// (acc, feat) => {
// acc[feat] = features.evalFeature(feat)
// return acc
// },
// {} as Record<Features, any>,
// )
// ax.logger.info('FEATURES', {
// features: feats,
// definitions: features.getFeatures(),
// })
// }
if (IS_DEV || IS_TESTFLIGHT) {
const feats = Object.values(Features).reduce(
(acc, feat) => {
acc[feat] = features.evalFeature(feat)
return acc
},
{} as Record<Features, any>,
)
ax.logger.info('FEATURES', {
features: feats,
definitions: features.getFeatures(),
})
}
})
return () => sub.remove()
}, [ax])
+2 -2
View File
@@ -9,11 +9,11 @@ export enum Features {
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
GroupChatsDisable = 'group_chats:disable',
GroupChatsEnable = 'group_chats:enable',
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
AATest = 'aa-test',
}
+4
View File
@@ -67,6 +67,10 @@ export class MetricsClient<M extends Record<string, any>> {
}
private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
logger.debug(`sendBatch: ${events.length}`, {
isRetry,
})
try {
const body = JSON.stringify({events})
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
+6 -133
View File
@@ -586,78 +586,9 @@ export type Events = {
| 'SendViaChatDialog'
| 'ConvoSettings'
}
// Group chat adoption
'groupchat:create': {
logContext: 'NewChatDialog'
}
'groupchat:landingPage:view': {
hasSession: boolean
}
'groupchat:inviteLink:redeem': {}
// Group chat user interactions
'groupchat:message:send': {
convoId: string
isOwner: boolean
}
'groupchat:mute': {
convoId: string
}
'groupchat:unmute': {
convoId: string
}
'groupchat:leave': {
convoId: string
isOwner: boolean
}
'groupchat:settings:view': {
convoId: string
isOwner: boolean
}
'groupchat:inviteLink:shareButton:press': {
convoId: string
method: 'post' | 'copy' | 'native'
}
'groupchat:inviteLink:shared': {
convoId: string
method: 'post' | 'dm'
}
// Group chat owner actions
'groupchat:owner:editName': {
convoId: string
}
'groupchat:owner:lock': {
convoId: string
}
'groupchat:owner:unlock': {
convoId: string
}
'groupchat:owner:kickMember': {
convoId: string
}
'groupchat:owner:inviteMember': {
convoId: string
}
'groupchat:owner:joinRequest:accept': {
convoId: string
}
'groupchat:owner:joinRequest:reject': {
convoId: string
}
'groupchat:owner:inviteLink:create': {
convoId: string
}
'groupchat:owner:inviteLink:disable': {
convoId: string
}
// Group chat problems
'groupchat:join:memberLimitReached': {
convoId: string
}
'starterPack:addUser': {
starterPack?: string
}
@@ -1244,76 +1175,18 @@ export type Events = {
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
// Post photo embed events
'post:photoEmbed:impression': {
layout: 'single' | 'grid' | 'carousel'
totalImages: number
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
'post:photoEmbed:open': {
layout: 'single' | 'grid' | 'carousel'
fromImage: number
totalImages: number
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
'post:photoEmbed:carouselSwipe': {
// Gallery carousel events
'post:gallery:swipe': {
fromImage: number
toImage: number
totalImages: number
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
'post:photoEmbed:lightboxSwipe': {
layout: 'single' | 'grid' | 'carousel'
'post:gallery:openLightbox': {
fromImage: number
toImage: number
totalImages: number
}
'post:gallery:impression': {
totalImages: number
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
/*
* Invite friends (profile QR share sheet)
*/
// NUX announcement dialog was shown to the user
'invite:nux:presented': {}
// user pressed "Try it" on the NUX announcement
'invite:nux:tryItPressed': {}
// invite friends dialog opened, with the surface that triggered it
'invite:dialog:open': {
logContext:
| 'ProfileHeader'
| 'Drawer'
| 'FindContactsSettings'
| 'NuxAnnouncement'
}
// user copied the invite link to clipboard
'invite:action:copy': {}
// user invoked the native share sheet with the invite link
'invite:action:share': {}
// user saved the QR code image to their camera roll (success only)
'invite:action:download': {}
// user pressed the scan button to open the QR scanner
'invite:action:scan': {}
// user changed the QR card color theme
'invite:theme:change': {
themeKey: 'dawn' | 'day' | 'dusk' | 'night'
}
// QR scanner decoded a code; result indicates whether it resolved to a profile
'invite:scanner:scanned': {
result: 'profileFound' | 'invalidQr'
}
// empty-followers banner promoting invite/find friends was shown
'invite:followersPromo:seen': {}
// user pressed the empty-followers promo banner
'invite:followersPromo:press': {}
// user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {}
}
+23 -12
View File
@@ -1,4 +1,4 @@
import {useEffect} from 'react'
import {useEffect, useMemo} from 'react'
import {View} from 'react-native'
import Animated, {
Easing,
@@ -8,10 +8,12 @@ import Animated, {
withDelay,
withTiming,
} from 'react-native-reanimated'
import {moderateProfile} from '@atproto/api'
import {
moderateProfile,
type ModerationOpts,
type ModerationUI,
} from '@atproto/api'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
@@ -31,9 +33,10 @@ export function AvatarBubbles({
profiles: allProfiles,
self = false,
size = 120,
moderationOpts,
}: {
animate?: boolean
profiles: (bsky.profile.AnyProfileView | undefined)[]
profiles: bsky.profile.AnyProfileView[]
/**
* By default, when there are more than 2 profiles, the current user is
* filtered out (so you don't see yourself among your own group's members).
@@ -42,12 +45,19 @@ export function AvatarBubbles({
*/
self?: boolean
size?: number
moderationOpts?: ModerationOpts
}) {
const {currentAccount} = useSession()
const profiles =
!self && allProfiles.length > 2
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
: allProfiles
const moderations = useMemo(() => {
if (!moderationOpts) return []
return profiles.map(p => {
return moderateProfile(p, moderationOpts)
})
}, [profiles, moderationOpts])
const scale = size / 120
const marginOffset = size < 120 ? -2 : 0
@@ -100,6 +110,7 @@ export function AvatarBubbles({
y={layout.y}
zIndex={layout.zIndex}
includeProfileBorder={layout.border}
moderation={moderations[i]?.ui('avatar')}
/>
))}
</View>
@@ -108,13 +119,14 @@ export function AvatarBubbles({
}
function AvatarBubble({
profile: profileUnshadowed,
profile,
scale,
size,
x,
y,
zIndex,
includeProfileBorder,
moderation,
}: {
profile?: bsky.profile.AnyProfileView
scale: SharedValue<number>
@@ -123,15 +135,14 @@ function AvatarBubble({
y: number
zIndex?: number
includeProfileBorder?: boolean
moderation?: ModerationUI
}) {
const t = useTheme()
const animatedStyle = useAnimatedStyle(() => ({
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
}))
const profile = useMaybeProfileShadow(profileUnshadowed)
const moderationOpts = useModerationOpts()
return (
<Animated.View
style={[
@@ -145,14 +156,14 @@ function AvatarBubble({
zIndex != null && {zIndex},
animatedStyle,
]}>
{profile && moderationOpts ? (
{profile ? (
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
moderation={moderation}
/>
) : (
<AvatarPlaceholder size={size} />
+3 -28
View File
@@ -45,7 +45,7 @@ export type ButtonColor =
| 'negative'
| 'primary_subtle'
| 'negative_subtle'
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
export type ButtonSize = 'tiny' | 'small' | 'large'
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
export type VariantProps = {
/**
@@ -136,7 +136,7 @@ export const Button = forwardRef<View, ButtonProps>(
(
{
children,
variant: variantProp,
variant,
color,
size,
shape = 'default',
@@ -160,8 +160,7 @@ export const Button = forwardRef<View, ButtonProps>(
* If a `color` is set, then we want to use the existing codepaths for
* "solid" buttons. This is to maintain backwards compatibility.
*/
let variant: VariantProps['variant'] = variantProp
if (!variantProp && color) {
if (!variant && color) {
variant = 'solid'
}
@@ -459,12 +458,6 @@ export const Button = forwardRef<View, ButtonProps>(
paddingHorizontal: 24,
gap: 6,
})
} else if (size === 'medium') {
baseStyles.push(a.rounded_full, {
paddingVertical: 9,
paddingHorizontal: 28,
gap: 5,
})
} else if (size === 'small') {
baseStyles.push(a.rounded_full, {
paddingVertical: 8,
@@ -486,13 +479,6 @@ export const Button = forwardRef<View, ButtonProps>(
borderRadius: 10,
gap: 3,
})
} else if (size === 'medium') {
baseStyles.push({
paddingVertical: 9,
paddingHorizontal: 16,
borderRadius: 8,
gap: 3,
})
} else if (size === 'small') {
baseStyles.push({
paddingVertical: 8,
@@ -519,12 +505,6 @@ export const Button = forwardRef<View, ButtonProps>(
} else {
baseStyles.push({height: 44, width: 44})
}
} else if (size === 'medium') {
if (shape === 'round') {
baseStyles.push({height: 33, width: 33})
} else {
baseStyles.push({height: 33, width: 33})
}
} else if (size === 'small') {
if (shape === 'round') {
baseStyles.push({height: 33, width: 33})
@@ -778,8 +758,6 @@ export function useSharedButtonTextStyles() {
if (size === 'large') {
baseStyles.push(a.text_md, a.font_medium)
} else if (size === 'medium') {
baseStyles.push(a.text_sm, a.font_medium)
} else if (size === 'small') {
baseStyles.push(a.text_sm, a.font_medium)
} else if (size === 'tiny') {
@@ -821,7 +799,6 @@ export function ButtonIcon({
size ??
(({
large: 'md',
medium: 'sm',
small: 'sm',
tiny: 'xs',
}[buttonSize || 'small'] || 'sm') as Exclude<
@@ -851,7 +828,6 @@ export function ButtonIcon({
*/
const iconContainerSize = {
large: 20,
medium: 17,
small: 17,
tiny: 15,
}[buttonSize || 'small']
@@ -865,7 +841,6 @@ export function ButtonIcon({
if (buttonShape === 'default') {
iconNegativeMargin = {
large: -2,
medium: -2,
small: -2,
tiny: -1,
}[buttonSize || 'small']
-1
View File
@@ -499,7 +499,6 @@ function TriggerClone({
accessibilityLabel={label}
accessibilityHint={_(msg`The subject of the context menu`)}
accessibilityIgnoresInvertColors={false}
cachePolicy="none"
/>
</Animated.View>
)
+38 -43
View File
@@ -218,7 +218,7 @@ export function Inner({children, style, header}: DialogInnerProps) {
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner(
{children, contentContainerStyle, header, footer, style, ...props},
{children, contentContainerStyle, header, style, ...props},
ref,
) {
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
@@ -248,45 +248,42 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
}
return (
<>
<ScrollView
style={[isHeightConstrained && a.flex_1, style]}
contentContainerStyle={[
a.pt_2xl,
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
platform({
ios: a.pb_2xl,
android: {
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
},
}),
contentContainerStyle,
]}
ref={ref}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
contentInsetAdjustmentBehavior={
isAtMaxSnapPoint ? 'automatic' : 'never'
}
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
{...props}
bounces={isAtMaxSnapPoint}
scrollEventThrottle={50}
// set drag state based on scroll on android.
// we want to detect if it's at the top or not, so watch
// scrollEndDrag and momentumScrollEnd as well
onScroll={android(onScroll)}
onScrollEndDrag={android(onScroll)}
onMomentumScrollEnd={android(onScroll)}
keyboardShouldPersistTaps="handled"
// TODO: figure out why this positions the header absolutely (rather than stickily)
// on Android. fine to disable for now, because we don't have any
// dialogs that use this that actually scroll -sfn
stickyHeaderIndices={ios(header ? [0] : undefined)}>
{header}
{children}
</ScrollView>
{footer}
</>
<ScrollView
style={[isHeightConstrained && a.flex_1, style]}
contentContainerStyle={[
a.pt_2xl,
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
platform({
ios: a.pb_2xl,
android: {
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
},
}),
contentContainerStyle,
]}
ref={ref}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
contentInsetAdjustmentBehavior={
isAtMaxSnapPoint ? 'automatic' : 'never'
}
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
{...props}
bounces={isAtMaxSnapPoint}
scrollEventThrottle={50}
// set drag state based on scroll on android.
// we want to detect if it's at the top or not, so watch
// scrollEndDrag and momentumScrollEnd as well
onScroll={android(onScroll)}
onScrollEndDrag={android(onScroll)}
onMomentumScrollEnd={android(onScroll)}
keyboardShouldPersistTaps="handled"
// TODO: figure out why this positions the header absolutely (rather than stickily)
// on Android. fine to disable for now, because we don't have any
// dialogs that use this that actually scroll -sfn
stickyHeaderIndices={ios(header ? [0] : undefined)}>
{header}
{children}
</ScrollView>
)
},
)
@@ -353,11 +350,9 @@ export const InnerFlatList = forwardRef<
export function FlatListFooter({
children,
onLayout,
border = true,
}: {
children: React.ReactNode
onLayout?: (event: LayoutChangeEvent) => void
border?: boolean
}) {
const t = useTheme()
const {bottom} = useSafeAreaInsets()
@@ -378,7 +373,7 @@ export function FlatListFooter({
a.bottom_0,
a.w_full,
a.z_10,
border && a.border_t,
a.border_t,
t.atoms.bg,
t.atoms.border_contrast_low,
a.px_lg,
+1 -5
View File
@@ -170,7 +170,6 @@ export function Inner({
accessibilityLabelledBy,
accessibilityDescribedBy,
header,
footer,
contentContainerStyle,
}: DialogInnerProps) {
const t = useTheme()
@@ -217,7 +216,6 @@ export function Inner({
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
{children}
</View>
{footer}
</DismissableLayer.DismissableLayer>
</View>
</FocusScope.FocusScope>
@@ -268,11 +266,9 @@ export const InnerFlatList = forwardRef<
export function FlatListFooter({
children,
onLayout,
border = true,
}: {
children: React.ReactNode
onLayout?: (event: LayoutChangeEvent) => void
border?: boolean
}) {
const t = useTheme()
@@ -285,7 +281,7 @@ export function FlatListFooter({
a.w_full,
a.z_10,
t.atoms.bg,
border && a.border_t,
a.border_t,
t.atoms.border_contrast_low,
a.px_lg,
a.py_md,
-4
View File
@@ -79,18 +79,14 @@ export type DialogInnerProps =
accessibilityLabelledBy: A11yProps['aria-labelledby']
accessibilityDescribedBy: string
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
contentContainerStyle?: StyleProp<ViewStyle>
header?: React.ReactNode
footer?: React.ReactNode
}>
| DialogInnerPropsBase<{
label: string
accessibilityLabelledBy?: undefined
accessibilityDescribedBy?: undefined
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
contentContainerStyle?: StyleProp<ViewStyle>
header?: React.ReactNode
footer?: React.ReactNode
}>
+13 -8
View File
@@ -1,10 +1,12 @@
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useGoBack} from '#/lib/hooks/useGoBack'
import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
export function Error({
@@ -13,20 +15,22 @@ export function Error({
onRetry,
onGoBack,
hideBackButton,
sideBorders = true,
}: {
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
hideBackButton?: boolean
sideBorders?: boolean
}) {
const {t: l} = useLingui()
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const goBack = useGoBack(onGoBack)
return (
<Layout.Center
<CenteredView
style={[
a.h_full_vh,
a.align_center,
@@ -34,7 +38,8 @@ export function Error({
!gtMobile && a.justify_between,
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}>
]}
sideBorders={sideBorders}>
<View style={[a.w_full, a.align_center, a.gap_lg]}>
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
<Text
@@ -53,7 +58,7 @@ export function Error({
<Button
variant="solid"
color="primary"
label={l`Press to retry`}
label={_(msg`Press to retry`)}
onPress={onRetry}
size="large">
<ButtonText>
@@ -65,7 +70,7 @@ export function Error({
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={l`Return to previous page`}
label={_(msg`Return to previous page`)}
onPress={goBack}
size="large">
<ButtonText>
@@ -74,6 +79,6 @@ export function Error({
</Button>
)}
</View>
</Layout.Center>
</CenteredView>
)
}
+6 -18
View File
@@ -1,5 +1,5 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
import {Pressable, StyleSheet, View} from 'react-native'
import {Image} from 'expo-image'
import {Trans, useLingui} from '@lingui/react/macro'
import {FocusGuards, FocusScope} from 'radix-ui/internal'
@@ -226,21 +226,17 @@ function LightboxGallery({
)}
</View>
{img.alt ? (
<ScrollView
// Cap the overlay height so long alt text scrolls within the overlay
// instead of growing past the top of the screen and pushing the image
// out of view. Only scrollable once expanded.
<View
style={[
styles.altScroll,
a.px_4xl,
a.py_2xl,
{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
// @ts-expect-error web only
backdropFilter: 'blur(16px)',
},
delayedFadeInAnim,
]}
scrollEnabled={isAltExpanded}
contentContainerStyle={[a.px_4xl, a.py_2xl]}>
]}>
<Pressable
accessibilityLabel={l`Expand alt text`}
accessibilityHint={l`If alt text is long, toggles alt text expanded state`}
@@ -254,7 +250,7 @@ function LightboxGallery({
{img.alt}
</Text>
</Pressable>
</ScrollView>
</View>
) : null}
{imgs.length > 1 && (
<div aria-live="polite" aria-atomic="true" style={a.sr_only}>
@@ -453,14 +449,6 @@ const styles = StyleSheet.create({
padding: 16,
boxSizing: 'border-box',
},
altScroll: {
// Size to content like the View it replaced, rather than filling the
// column via ScrollView's default flexGrow.
flexGrow: 0,
flexShrink: 0,
// @ts-ignore web-only -sfn
maxHeight: '50vh',
},
menuBtn: {
top: 20,
left: 20,
+1 -11
View File
@@ -1,9 +1,6 @@
import {useRef} from 'react'
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {BlurView} from 'expo-blur'
import {useLingui} from '@lingui/react/macro'
@@ -20,16 +17,10 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
const {t: l} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {height: screenHeight} = useSafeAreaFrame()
const isMomentumScrolling = useRef(false)
if (!altText) return null
// Cap the overlay height so long alt text - or text enlarged by the OS via
// Dynamic Type / font scaling - scrolls within the overlay instead of growing
// past the top of the screen. Leaves the upper half clear for the header.
const maxHeight = screenHeight / 2
return (
<View
style={[
@@ -55,7 +46,6 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
}),
]}>
<ScrollView
style={{maxHeight}}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
+5 -2
View File
@@ -10,7 +10,7 @@ import Animated, {
} from 'react-native-reanimated'
import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {android, atoms as a, ios} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {type Props as IconProps} from '#/components/icons/common'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
@@ -25,6 +25,7 @@ type Props = {
type Anchor = {x: number; y: number; width: number; height: number}
const MENU_WIDTH = 160
const GAP = 6
const CARD_BG = '#000000'
const CARD_BORDER = '#232e3e'
@@ -123,8 +124,9 @@ function MenuCard({
<Animated.View
style={[
a.absolute,
a.self_start,
styles.card,
android({alignSelf: 'flex-start'}),
ios({width: MENU_WIDTH}),
{
top: anchor.y + anchor.height + GAP,
left: anchor.x,
@@ -184,6 +186,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
itemText: {
flex: 1,
fontSize: 15,
fontWeight: '500',
lineHeight: 19.5,
+16 -27
View File
@@ -1,5 +1,6 @@
import {StyleSheet, View} from 'react-native'
import {BlurView} from 'expo-blur'
import {atoms as a} from '#/alf'
type Props = {
count: number
@@ -13,38 +14,26 @@ const GAP = 5
export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null
return (
<View style={styles.root}>
<BlurView intensity={20} tint="dark" style={styles.inner}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</BlurView>
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</View>
)
}
const styles = StyleSheet.create({
root: {
borderRadius: 999,
overflow: 'hidden',
},
inner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
row: {
gap: GAP,
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
activeDot: {
width: ACTIVE,
@@ -1,62 +0,0 @@
import {StyleSheet, View} from 'react-native'
type Props = {
count: number
activeIndex: number
}
const ACTIVE = 6
const INACTIVE = 4
const GAP = 5
export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null
return (
<View style={styles.root}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</View>
)
}
const styles = StyleSheet.create({
root: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: GAP,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
// @ts-expect-error web-only
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
},
activeDot: {
width: ACTIVE,
height: ACTIVE,
borderRadius: ACTIVE / 2,
},
inactiveDot: {
width: INACTIVE,
height: INACTIVE,
borderRadius: INACTIVE / 2,
},
active: {
backgroundColor: '#fff',
},
inactive: {
backgroundColor: 'rgba(255, 255, 255, 0.4)',
},
})
@@ -246,7 +246,6 @@ const ImageItem = ({
}
}
cachePolicy="memory"
useAppleWebpCodec
/>
</Animated.View>
</Animated.View>
+2 -22
View File
@@ -32,14 +32,12 @@ import Animated, {
withSpring,
type WithSpringConfig,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import * as ScreenOrientation from 'expo-screen-orientation'
import {type Dimensions} from '#/lib/media/types'
import {useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {type Lightbox} from '#/components/Lightbox/state'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
import {Footer} from '../chrome/Footer'
@@ -138,9 +136,6 @@ export default function ImageViewRoot({
'worklet'
thumbRects.set({})
})()
requestIdleCallback(() => {
void Image.clearMemoryCache()
})
}, [thumbRects])
useAnimatedReaction(
@@ -229,8 +224,7 @@ function ImageView({
openProgress: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
}) {
const {images, index: initialImageIndex, metricsContext} = lightbox
const ax = useAnalytics()
const {images, index: initialImageIndex} = lightbox
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -379,21 +373,7 @@ function ImageView({
scrollEnabled={!isScaled}
initialPage={initialImageIndex}
onPageSelected={e => {
const next = e.nativeEvent.position
setImageIndex(prev => {
if (metricsContext && prev !== next) {
ax.metric('post:photoEmbed:lightboxSwipe', {
layout: metricsContext.layout,
fromImage: prev + 1,
toImage: next + 1,
totalImages: images.length,
postUri: metricsContext.postUri,
postAuthorDid: metricsContext.postAuthorDid,
feedDescriptor: metricsContext.feedDescriptor,
})
}
return next
})
setImageIndex(e.nativeEvent.position)
setIsScaled(false)
}}
onPageScrollStateChanged={e => {
-10
View File
@@ -11,20 +11,10 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useHotkeysContext} from '#/lib/hotkeys'
import {type ImageSource} from '#/components/Lightbox/types'
export type LightboxMetricsContext = {
layout: 'single' | 'grid' | 'carousel'
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
export type Lightbox = {
id: string
images: ImageSource[]
index: number
// Set for post photo embeds so the lightbox can emit post:photoEmbed:lightboxSwipe.
// Left unset for non-post contexts (e.g. profile avatar/banner lightbox).
metricsContext?: LightboxMetricsContext
}
const LightboxContext = createContext<{
+4 -99
View File
@@ -6,7 +6,6 @@ import {
type TargetedEvent,
} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {useLingui} from '@lingui/react/macro'
import {
type LinkProps as RNLinkProps,
StackActions,
@@ -27,14 +26,11 @@ import {
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
import {useModalControls} from '#/state/modals'
import {useInAppBrowser} from '#/state/preferences/in-app-browser'
import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
import {Button, type ButtonProps} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import * as PeekMenu from '#/components/PeekMenu'
import {Text, type TextProps} from '#/components/Typography'
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
import {IS_NATIVE, IS_WEB} from '#/env'
import {router} from '#/routes'
import {useGlobalDialogsControlContext} from './dialogs/Context'
@@ -90,13 +86,6 @@ type BaseLinkProps = {
*/
shouldProxy?: boolean
/**
* iOS only. Wraps the link in a peek menu: long-pressing previews the live
* page in an in-app browser (morphing into it on tap when the in-app browser
* preference is on), with a share action in the menu. No-op elsewhere.
*/
peek?: boolean
/**
* Web only
*/
@@ -286,19 +275,11 @@ export function useLink({
[outerOnLongPress, handleLongPress, shareOnLongPress],
)
// Opens the link through the normal external flow (consent dialog, in-app
// browser, or system browser per preference). Used by the peek menu when the
// in-app browser is off, so committing the peek behaves like a plain tap.
const openExternally = useCallback(() => {
void openLink(href, overridePresentation, shouldProxy)
}, [openLink, href, overridePresentation, shouldProxy])
return {
isExternal,
href,
onPress,
onLongPress,
openExternally,
}
}
@@ -324,10 +305,9 @@ export function Link({
download,
shouldProxy,
overridePresentation,
peek,
...rest
}: LinkProps) {
const {href, isExternal, onPress, onLongPress, openExternally} = useLink({
const {href, isExternal, onPress, onLongPress} = useLink({
to,
displayText: typeof children === 'string' ? children : '',
action,
@@ -337,10 +317,7 @@ export function Link({
overridePresentation,
})
// Peek is iOS-only and only makes sense for external web links.
const peekEnabled = Boolean(peek && IS_IOS && isExternal)
const button = (
return (
<Button
{...rest}
style={[a.justify_start, rest.style]}
@@ -348,11 +325,7 @@ export function Link({
accessibilityRole="link"
href={href}
onPress={download ? undefined : onPress}
// When peeking, the native long-press drives the context menu. A
// simultaneous RN long-press recognizer fights it — cancelling the tap
// and breaking the lift animation — so leave long-press to native and
// surface sharing through the peek menu instead.
onLongPress={peekEnabled ? undefined : onLongPress}
onLongPress={onLongPress}
{...web({
hrefAttrs: {
target: download ? undefined : isExternal ? 'blank' : undefined,
@@ -367,74 +340,6 @@ export function Link({
{children}
</Button>
)
if (peekEnabled) {
// Match the lift radius to whatever the consumer styled the link with, so
// the peek animation clips to the same corners as the rendered card.
const borderRadius = flatten(rest.style)?.borderRadius
return (
<LinkPeek
href={href}
onPreviewPress={openExternally}
shouldProxy={shouldProxy}
borderRadius={typeof borderRadius === 'number' ? borderRadius : 0}>
{button}
</LinkPeek>
)
}
return button
}
/**
* iOS peek-menu wrapper for an external `Link`. Long-pressing previews the live
* page in an in-app browser; the in-app-browser preference decides whether
* tapping the peek morphs into the browser or hands off to the normal link flow
* via `onPreviewPress`. The menu carries a Share action.
*/
function LinkPeek({
href,
onPreviewPress,
shouldProxy,
borderRadius,
children,
}: {
href: string
onPreviewPress: () => void
shouldProxy?: boolean
borderRadius: number
children: React.ReactNode
}) {
const t = useTheme()
const {t: l} = useLingui()
const useInAppBrowserPref = useInAppBrowser()
return (
<PeekMenu.Root>
<PeekMenu.Trigger
preview={{
type: 'link',
url: shouldProxy ? createProxiedUrl(href) : href,
// Only morph natively when the user has explicitly opted in. When the
// preference is unset (undefined), defer to the JS flow so the consent
// dialog can show.
useInAppBrowser: useInAppBrowserPref === true,
browserToolbarColor: t.atoms.bg.backgroundColor,
browserControlsColor: t.palette.primary_500,
}}
borderRadius={borderRadius}
// Fires only when not morphing natively (in-app browser off/unset).
onPreviewPress={onPreviewPress}>
{children}
</PeekMenu.Trigger>
<PeekMenu.Menu>
<PeekMenu.MenuItem id="share" onSelect={() => void shareUrl(href)}>
<PeekMenu.MenuItemIcon icon={ShareIcon} />
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
</PeekMenu.Menu>
</PeekMenu.Root>
)
}
export type InlineLinkProps = React.PropsWithChildren<
+2
View File
@@ -185,6 +185,7 @@ let ListMaybePlaceholder = ({
message={errorMessage ?? _(msg`Something went wrong!`)}
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
hideBackButton={hideBackButton}
/>
)
@@ -225,6 +226,7 @@ let ListMaybePlaceholder = ({
onRetry={onRetry}
onGoBack={onGoBack}
hideBackButton={hideBackButton}
sideBorders={sideBorders}
/>
)
}
+3 -38
View File
@@ -1,10 +1,6 @@
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {
AppBskyEmbedGallery,
type AppBskyEmbedImages,
type AppBskyFeedDefs,
} from '@atproto/api'
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {shareImageModal} from '#/lib/media/manip'
@@ -51,34 +47,6 @@ export function Embed({
)}
</Outer>
)
} else if (e.type === 'gallery') {
// Notification/DM preview is a narrow inline strip; cap at 4 tiles so
// a 10-image gallery doesn't blow out the row width. Single pass instead
// of filter().slice().map() so we stop at 4 viewable items rather than
// walking every item in a 10-image gallery.
const tiles: React.ReactNode[] = []
for (const item of e.view.items) {
if (tiles.length >= 4) break
if (!AppBskyEmbedGallery.isViewImage(item)) continue
if (peekable) {
const image: AppBskyEmbedImages.ViewImage = {
thumb: item.thumbnail,
fullsize: item.fullsize,
alt: item.alt,
aspectRatio: item.aspectRatio,
}
tiles.push(<PeekableImageItem key={item.thumbnail} image={image} />)
} else {
tiles.push(
<ImageItem
key={item.thumbnail}
thumbnail={item.thumbnail}
alt={item.alt}
/>,
)
}
}
return <Outer style={style}>{tiles}</Outer>
} else if (e.type === 'link') {
if (!e.view.external.thumb) return null
if (!isGifEmbed(e.view.external.uri)) return null
@@ -127,12 +95,10 @@ export function ImageItem({
thumbnail,
alt,
children,
maxWidth = 100,
}: {
thumbnail?: string
alt?: string
children?: React.ReactNode
maxWidth?: number
}) {
const t = useTheme()
@@ -143,7 +109,7 @@ export function ImageItem({
{backgroundColor: 'black'},
a.flex_1,
a.aspect_square,
{maxWidth},
{maxWidth: 100},
a.rounded_xs,
]}
accessibilityLabel={alt}
@@ -154,7 +120,7 @@ export function ImageItem({
}
return (
<View style={[a.flex_grow, a.relative, a.aspect_square, {maxWidth}]}>
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
<Image
key={thumbnail}
source={{uri: thumbnail}}
@@ -163,7 +129,6 @@ export function ImageItem({
contentFit="cover"
accessible={true}
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<MediaInsetBorder style={[a.rounded_xs]} />
{children}
+1 -1
View File
@@ -172,7 +172,7 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
return (
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>Follows you</Trans>
<Trans>Follows You</Trans>
</Text>
</View>
)
@@ -1,48 +0,0 @@
import {type StyleProp, type ViewStyle} from 'react-native'
import {type AppBskyEmbedExternal} from '@atproto/api'
import {atoms as a} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
import {JoinRequestEmbedBody} from '#/components/Post/Embed/JoinRequestEmbed'
/**
* Renders a chat invite link found in an `app.bsky.embed.external` embed (e.g.
* a `bsky.app/chat/<code>` link posted to the feed) as a join request card,
* falling back to a plain external embed if the invite can't be resolved.
*/
export function ChatInviteEmbed({
code,
link,
onOpen,
style,
}: {
code: string
link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
return (
<ChatInvite.Root code={code} hasFixedHeight>
<ChatInviteEmbedBody link={link} onOpen={onOpen} style={style} />
</ChatInvite.Root>
)
}
function ChatInviteEmbedBody({
link,
onOpen,
style,
}: {
link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
const {status} = ChatInvite.useChatInvite()
if (status === 'error') {
return <ExternalEmbed link={link} onOpen={onOpen} style={style} />
}
return <JoinRequestEmbedBody style={[a.mt_sm, style]} onOpen={onOpen} />
}
@@ -1,4 +1,4 @@
import {useMemo} from 'react'
import {useCallback, useMemo} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedExternal} from '@atproto/api'
@@ -51,19 +51,17 @@ export const ExternalEmbed = ({
}, [link.uri, externalEmbedPrefs])
const hasMedia = Boolean(imageUri || embedPlayerParams)
const onPress = () => {
const onPress = useCallback(() => {
playHaptic('Light')
onOpen?.()
}
}, [playHaptic, onOpen])
const onShareExternal = IS_NATIVE
? () => {
if (link.uri) {
playHaptic('Heavy')
void shareUrl(link.uri)
}
}
: undefined
const onShareExternal = useCallback(() => {
if (link.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(link.uri)
}
}, [link.uri, playHaptic])
if (
embedPlayerParams?.source === 'tenor' ||
@@ -88,8 +86,6 @@ export const ExternalEmbed = ({
label={link.title || _(msg`Open link to ${niceUrl}`)}
to={link.uri}
shouldProxy={true}
peek
style={[a.rounded_md]}
onPress={onPress}
onLongPress={onShareExternal}>
{({hovered}) => (
@@ -101,7 +97,6 @@ export const ExternalEmbed = ({
a.overflow_hidden,
a.w_full,
a.border,
t.atoms.bg,
style,
hovered
? t.atoms.border_contrast_high
@@ -113,7 +108,6 @@ export const ExternalEmbed = ({
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : undefined}
@@ -1,99 +0,0 @@
import {Linking, View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Sparkle_Stroke2_Corner0_Rounded as Sparkle} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
/**
* OTA-able fallback that ships to native builds which don't yet know how to
* render the new gallery embed (>4 images, Photos v2). Final copy and visual
* treatment pending design from Darrin/Danielle/Alex.
*
* Native-only per APP-2308 - web builds receive the new gallery support in
* the same release that adds it.
*/
export function GalleryFallbackEmbed({count}: {count?: number}) {
const t = useTheme()
const {t: l} = useLingui()
const bodyStyle = [
a.text_sm,
a.text_center,
a.leading_snug,
t.atoms.text_contrast_high,
]
return (
<View
style={[
a.mt_sm,
a.rounded_md,
a.border,
a.p_lg,
a.pb_2xl,
a.gap_sm,
a.align_center,
{
borderColor: t.palette.primary_200,
backgroundColor: t.palette.primary_25,
},
]}>
<Sparkle size="lg" fill={t.palette.primary_500} />
<Text style={[a.text_md, a.font_bold, a.text_center, t.atoms.text]}>
<Trans>Something new is here</Trans>
</Text>
{count ? (
<View>
<Text style={bodyStyle}>
{plural(count, {
one: 'This post has # photo.',
other: 'This post has # photos.',
})}
</Text>
{IS_NATIVE ? (
<Text style={bodyStyle}>
{plural(count, {
one: 'Update your app to see it.',
other: 'Update your app to see them all.',
})}
</Text>
) : (
<Text style={bodyStyle}>
{plural(count, {
one: 'Refresh the page to see it.',
other: 'Refresh the page to see them all.',
})}
</Text>
)}
</View>
) : IS_NATIVE ? (
<Text style={bodyStyle}>
<Trans>Update your app to see it.</Trans>
</Text>
) : (
<Text style={bodyStyle}>
<Trans>Refresh the page to see it.</Trans>
</Text>
)}
{IS_NATIVE && (
<Button
label={l`Update your app`}
size="small"
color="primary"
onPress={() => {
void Linking.openURL(BSKY_DOWNLOAD_URL)
}}
style={[a.mt_xs]}>
<ButtonText>
<Trans>Update app</Trans>
</ButtonText>
</Button>
)}
</View>
)
}
+5 -47
View File
@@ -2,16 +2,12 @@ import {useRef} from 'react'
import {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {AppBskyEmbedGallery, type AppBskyEmbedImages} from '@atproto/api'
import {atoms as a, tokens} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {
type LightboxMetricsContext,
useLightboxControls,
} from '#/components/Lightbox/state'
import {useLightboxControls} from '#/components/Lightbox/state'
import {type Dimensions} from '#/components/Lightbox/types'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
@@ -19,43 +15,16 @@ import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
const MAX_GRID_IMAGES = 4
export function ImageEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'images'> | EmbedType<'gallery'>
embed: EmbedType<'images'>
}) {
const ax = useAnalytics()
const {openLightbox} = useLightboxControls()
const images: AppBskyEmbedImages.ViewImage[] =
embed.type === 'gallery'
? embed.view.items.filter(AppBskyEmbedGallery.isViewImage).map(item => ({
thumb: item.thumbnail,
fullsize: item.fullsize,
alt: item.alt,
aspectRatio: item.aspectRatio,
}))
: embed.view.images
const useExpandedLayout =
embed.type === 'gallery'
? images.length > MAX_GRID_IMAGES
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
const layout: 'single' | 'grid' | 'carousel' =
images.length === 1 ? 'single' : useExpandedLayout ? 'carousel' : 'grid'
const postContext = rest.post
? {
postUri: rest.post.uri,
postAuthorDid: rest.post.author.did,
feedDescriptor: rest.feedDescriptor,
}
: undefined
const metricsContext: LightboxMetricsContext | undefined = postContext
? {layout, ...postContext}
: undefined
const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact.
@@ -74,14 +43,6 @@ export function ImageEmbed({
refs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => {
if (postContext) {
ax.metric('post:photoEmbed:open', {
layout,
fromImage: index + 1,
totalImages: images.length,
...postContext,
})
}
openLightbox({
images: items.map((item, i) => ({
...item,
@@ -92,7 +53,6 @@ export function ImageEmbed({
type: 'image',
})),
index,
metricsContext,
})
}
const onPressIn = (_: number) => {
@@ -149,7 +109,7 @@ export function ImageEmbed({
)
}
if (useExpandedLayout) {
if (galleryEnabled) {
return (
<View style={[a.mt_sm, rest.style]}>
<Gallery
@@ -158,7 +118,6 @@ export function ImageEmbed({
onPressIn={onPressIn}
viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
metricsPostContext={postContext}
/>
</View>
)
@@ -171,7 +130,6 @@ export function ImageEmbed({
onPress={onPress}
onPressIn={onPressIn}
viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
/>
</View>
)
@@ -1,83 +0,0 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {
type ChatInvitePreview,
isKnownJoinLinkPreview,
} from '#/state/queries/join-links'
import {atoms as a, useTheme} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
const JOIN_REQUEST_EMBED_HEIGHT = 140
/**
* The "join request" presentation of a chat invite, used as a post embed (in
* feeds and the post composer). Composes the headless `ChatInvite` primitive:
* pass either a `code` to fetch by, or an already-resolved `preview` as the
* initial data to avoid a loading flash.
*/
export function JoinRequestEmbed({
code,
preview,
style,
onOpen,
}: {
code?: string
preview?: ChatInvitePreview
style?: StyleProp<ViewStyle>
onOpen?: () => void
}) {
const resolvedCode =
code ?? (isKnownJoinLinkPreview(preview) ? preview.code : undefined)
if (!resolvedCode) return null
return (
<ChatInvite.Root
code={resolvedCode}
initialPreview={preview}
hasFixedHeight>
<JoinRequestEmbedBody style={style} onOpen={onOpen} />
</ChatInvite.Root>
)
}
/**
* The context-consuming presentation (loading / no-longer-available / card +
* join button). Exported so surfaces that own their own `ChatInvite.Root` (e.g.
* to add an error fallback) can render it without nesting another Root.
*/
export function JoinRequestEmbedBody({
style,
onOpen,
}: {
style?: StyleProp<ViewStyle>
onOpen?: () => void
}) {
const t = useTheme()
const {status} = ChatInvite.useChatInvite()
const box = [
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
]
if (status === 'loading') {
return <ChatInvite.Loading style={[box, a.p_lg, style]} />
}
if (status !== 'available') {
return (
<ChatInvite.Unavailable
style={[box, a.p_lg, t.atoms.bg_contrast_25, style]}
/>
)
}
return (
<View style={[a.justify_between, a.p_lg, a.gap_lg, box, style]}>
<ChatInvite.Card size="large" />
<ChatInvite.JoinButton onPress={onOpen} />
</View>
)
}
@@ -1,10 +1,13 @@
import {Fragment, type ReactNode} from 'react'
import {View} from 'react-native'
import {AtUri} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {StandardSite} from '#/components/icons/community/StandardSite'
import {InlineLinkText} from '#/components/Link'
import {
matchStandardSitePublisher,
matchStandardSitePublisherByUri,
@@ -15,15 +18,19 @@ import {
isStandardSitePublicationUri,
} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
export function StandardSiteMetaRow({
type = 'document',
preview,
view,
}: ssTypes.CommonProps &
ssTypes.PreviewProps & {
type?: 'document' | 'publication'
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const highlightedPublisher = !!matchStandardSitePublisher(view)
const didsFromRecords =
view.associatedRefs
@@ -40,11 +47,7 @@ export function StandardSiteMetaRow({
: undefined
const articleDomain = toNiceDomain(view.uri)
const articlePublisher = matchStandardSitePublisherByUri(view.uri)
const domainHandleMatch =
authorProfile?.handle &&
(articleDomain === authorProfile.handle ||
articleDomain.endsWith(`.${authorProfile.handle}`))
const DomainIcon = articlePublisher?.Icon
const DomainIcon = articlePublisher?.Icon || StandardSite
const metaTextStyle = [
a.text_xs,
a.leading_tight,
@@ -53,7 +56,7 @@ export function StandardSiteMetaRow({
const items: {key: string; node: ReactNode}[] = []
if (!highlightedPublisher && !domainHandleMatch) {
if (!highlightedPublisher) {
items.push({
key: 'domain',
node: (
@@ -74,7 +77,25 @@ export function StandardSiteMetaRow({
key: 'author',
node: (
<Text numberOfLines={1} style={[metaTextStyle]}>
<Trans>by @{authorProfile.handle}</Trans>
<Trans>
by{' '}
<InlineLinkText
label={l`View @${authorProfile.handle}'s profile`}
to={makeProfileLink(authorProfile)}
style={[
metaTextStyle,
preview ? a.pointer_events_none : a.pointer_events_auto,
]}
onPress={e => {
e.stopPropagation()
e.preventDefault()
ax.metric('embed:standardSite:authorHandle:press', {
handle: authorProfile.handle,
})
}}>
@{authorProfile.handle}
</InlineLinkText>
</Trans>
</Text>
),
})
@@ -5,6 +5,7 @@ import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
import {useCallOnce} from '#/lib/once'
import {shareUrl} from '#/lib/sharing'
import {niceDate} from '#/lib/strings/time'
import {toNiceDomain} from '#/lib/strings/url-helpers'
@@ -15,7 +16,6 @@ import {Divider} from '#/components/Divider'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {StandardSite} from '#/components/icons/community/StandardSite'
import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
@@ -80,15 +80,13 @@ export const StandardSiteEmbed = ({
onEmbedInteractionCallback?.()
ax.metric('embed:standardSite:article:press', {url: view.uri})
}
const onLongPress = IS_NATIVE
? () => {
if (view.uri) {
playHaptic('Heavy')
void shareUrl(view.uri)
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
}
}
: undefined
const onLongPress = () => {
if (view.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.uri)
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
}
}
const onPressPublication = () => {
playHaptic('Light')
onEmbedInteractionCallback?.()
@@ -96,17 +94,21 @@ export const StandardSiteEmbed = ({
url: view.source?.uri || '',
})
}
const onLongPressPublication = IS_NATIVE
? () => {
if (view.source?.uri) {
playHaptic('Heavy')
void shareUrl(view.source.uri)
ax.metric('embed:standardSite:publication:longPress', {
url: view.source.uri,
})
}
}
: undefined
const onLongPressPublication = () => {
if (view.source?.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.source.uri)
ax.metric('embed:standardSite:publication:longPress', {
url: view.source.uri,
})
}
}
useCallOnce(() => {
if (!preview) {
ax.metric('embed:standardSite:view', {url: view.uri})
}
})()
if (isStandardPublication) {
return (
@@ -164,7 +166,6 @@ export const StandardSiteEmbed = ({
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : undefined}
@@ -354,7 +355,6 @@ export function PublicationCard({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
emoji
numberOfLines={1}
style={[
a.text_md,
@@ -385,7 +385,7 @@ export function PublicationCard({
<View style={[a.pointer_events_none]}>
{view.description && (
<View style={[a.pt_sm]}>
<Text emoji style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
{view.description}
</Text>
</View>
@@ -425,26 +425,6 @@ export function SubscribeButton({
? l`Subscribe on ${highlightedPublisher.name}`
: l`View publication`
/*
* The custom site theme paints the button background with `accent` and the
* text with `accentForeground`. Only honor it when that pairing clears WCAG
* AAA (4.5:1) for large text, which the button's bold label qualifies as.
* Otherwise we fall through to the default `secondary_inverted` styling,
* which is guaranteed to be legible.
*/
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
let useCustomTheme = false
if (accentRGB && accentForegroundRGB) {
const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
const accentForeground = utils.rgbToHex(
accentForegroundRGB.r,
accentForegroundRGB.g,
accentForegroundRGB.b,
)
const ratio = utils.contrastRatio(accent, accentForeground)
useCustomTheme = ratio !== null && ratio >= 4.5
}
if (!view.source) return null
const publicationTitle = view.source.title
@@ -470,60 +450,52 @@ export function SubscribeButton({
}
}
const onLongPress = IS_NATIVE
? () => {
if (view.source?.uri) {
playHaptic('Heavy')
void shareUrl(view.source.uri)
if (highlightedPublisher) {
ax.metric('embed:standardSite:subscribe:longPress', {
url: view.source?.uri || '',
})
} else {
ax.metric('embed:standardSite:publicationCta:longPress', {
url: view.source?.uri || '',
})
}
}
const onLongPress = () => {
if (view.source?.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.source.uri)
if (highlightedPublisher) {
ax.metric('embed:standardSite:subscribe:longPress', {
url: view.source?.uri || '',
})
} else {
ax.metric('embed:standardSite:publicationCta:longPress', {
url: view.source?.uri || '',
})
}
: undefined
const button = (
<Link
shouldProxy
to={view.source.uri}
label={label}
size="small"
color="secondary_inverted"
style={[
style,
a.gap_sm,
preview ? a.pointer_events_none : a.pointer_events_auto,
]}
onPress={onPress}
onLongPress={onLongPress}>
{highlightedPublisher ? (
<>
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
</View>
<ButtonText>{cta}</ButtonText>
</>
) : (
<>
<ButtonText>{cta}</ButtonText>
<ButtonIcon icon={ArrowTopRightIcon} />
</>
)}
</Link>
)
if (!useCustomTheme) {
return button
}
}
return (
<StandardSiteThemeProvider view={view}>{button}</StandardSiteThemeProvider>
<StandardSiteThemeProvider view={view}>
<Link
shouldProxy
to={view.source.uri}
label={label}
size="small"
color="secondary_inverted"
style={[
style,
a.gap_sm,
preview ? a.pointer_events_none : a.pointer_events_auto,
]}
onPress={onPress}
onLongPress={onLongPress}>
{highlightedPublisher ? (
<>
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
</View>
<ButtonText>{cta}</ButtonText>
</>
) : (
<>
<ButtonText>{cta}</ButtonText>
<ButtonIcon icon={ArrowTopRightIcon} />
</>
)}
</Link>
</StandardSiteThemeProvider>
)
}
@@ -536,9 +508,8 @@ function PublicationIcon({
interacted?: boolean
themeColors: ssTypes.ThemeColors
}) {
const t = useTheme()
if (!view.source) return null
const icon = view.source?.icon ? (
return view.source?.icon ? (
<View>
<UserAvatar
noBorder
@@ -569,29 +540,6 @@ function PublicationIcon({
<MediaInsetBorder opaque style={[a.rounded_sm]} />
</View>
)
return (
<View style={[a.relative]}>
<View
style={[
a.absolute,
a.rounded_full,
a.z_10,
a.justify_center,
a.align_center,
t.atoms.bg,
{
width: 16,
height: 16,
top: -6,
left: -6,
},
]}>
<StandardSite size="xs" fill={t.atoms.text_contrast_medium.color} />
<MediaInsetBorder />
</View>
{icon}
</View>
)
}
export function PublicationFooter({
@@ -668,7 +616,6 @@ export function PublicationFooter({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
emoji
numberOfLines={1}
style={[
a.text_sm,
@@ -108,15 +108,10 @@ export function useActiveVideoWeb() {
return {
active: activeViewId === id,
setActive: useCallback(() => {
setActive: () => {
setActiveView(id)
}, [setActiveView, id]),
},
currentActiveView: activeViewId,
sendPosition: useCallback(
(y: number) => {
sendViewPosition(id, y)
},
[sendViewPosition, id],
),
sendPosition: (y: number) => sendViewPosition(id, y),
}
}
+1 -23
View File
@@ -12,7 +12,6 @@ import {Trans} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {makeProfileLink} from '#/lib/routes/links'
import {getChatInviteCodeFromUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session'
@@ -34,7 +33,6 @@ import {
type EmbedType,
parseEmbed,
} from '#/types/bsky/post'
import {ChatInviteEmbed} from './ChatInviteEmbed'
import {ExternalEmbed} from './ExternalEmbed'
import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed'
@@ -54,7 +52,6 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
switch (embed.type) {
case 'images':
case 'gallery':
case 'link':
case 'video': {
return <MediaEmbed embed={embed} {...rest} />
@@ -90,8 +87,7 @@ function MediaEmbed({
embed: TEmbed
}) {
switch (embed.type) {
case 'images':
case 'gallery': {
case 'images': {
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
@@ -114,21 +110,6 @@ function MediaEmbed({
</ContentHider>
)
}
const chatInviteCode = getChatInviteCodeFromUrl(embed.view.external.uri)
if (chatInviteCode) {
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<ChatInviteEmbed
code={chatInviteCode}
link={embed.view.external}
onOpen={rest.onOpen}
style={rest.style}
/>
</ContentHider>
)
}
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
@@ -345,9 +326,6 @@ export function QuoteEmbed({
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
// The photo embed belongs to the quoted post, so attribute its
// analytics to the quoted post rather than the parent.
post={quote}
/>
)}
</>
-7
View File
@@ -15,13 +15,6 @@ export type CommonProps = {
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
allowNestedQuotes?: boolean
/**
* The post that contains this embed. Used for analytics on photo embed
* events (post:photoEmbed:*). When the embed has no owning post (e.g.
* composer previews), leave this undefined and no events will be emitted.
*/
post?: AppBskyFeedDefs.PostView
feedDescriptor?: string
}
export type EmbedProps = CommonProps & {
@@ -88,7 +88,6 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import {BlockDialog} from '#/components/moderation/BlockDialog'
import {
ReportDialog,
useReportDialogControl,
@@ -551,17 +550,9 @@ let PostMenuItems = ({
) : (
<Menu.Item
testID="postDropdownTranslateBtn"
label={
forceGoogleTranslate
? l`Open in Google Translate`
: l`Translate`
}
label={l`Translate`}
onPress={onPressTranslate}>
<Menu.ItemText>
{forceGoogleTranslate
? l`Open in Google Translate`
: l`Translate`}
</Menu.ItemText>
<Menu.ItemText>{l`Translate`}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
)}
@@ -854,10 +845,13 @@ let PostMenuItems = ({
onConfirm={() => void onToggleReplyVisibility()}
confirmButtonCta={l`Yes, hide`}
/>
<BlockDialog
<Prompt.Basic
control={blockPromptControl}
profile={postAuthor}
onBlock={onBlockAuthor}
title={l`Block Account?`}
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
onConfirm={() => void onBlockAuthor()}
confirmButtonCta={l`Block`}
confirmButtonColor="negative"
/>
</>
)
@@ -152,7 +152,11 @@ function RecentChatItem({
a.align_center,
]}>
{convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size={WIDTH - 8} />
<AvatarBubbles
profiles={convo.members}
size={WIDTH - 8}
moderationOpts={moderationOpts}
/>
) : (
<UserAvatar
avatar={primaryProfile.avatar}
+6 -11
View File
@@ -1,8 +1,7 @@
import {View} from 'react-native'
import {useWindowDimensions, View} from 'react-native'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
import {useNativeFontScale} from '#/alf/util/dimensions'
import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
@@ -32,16 +31,14 @@ export function ProfileBadges({
interactive = false,
size,
style,
allowFontScaling = true,
}: ViewStyleProp & {
profile: bsky.profile.AnyProfileView
interactive?: boolean
size: Size
allowFontScaling?: boolean
}) {
const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile})
const nativeScaleMultiplier = useNativeFontScale()
const {fontScale: nativeScaleMultiplier} = useWindowDimensions()
const {
fonts: {scaleMultiplier: alfScaleMultiplier},
} = useAlf()
@@ -51,12 +48,10 @@ export function ProfileBadges({
const isOnTheSmallSide = size === 'xs' || size === 'sm'
const scaleMultiplier = allowFontScaling
? nativeScaleMultiplier * alfScaleMultiplier
: 1
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
const botIconWidth = botIconSizes[size] * scaleMultiplier
const verificationIconWidth =
verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
const botIconWidth =
botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
return (
<View
+4 -7
View File
@@ -1,7 +1,8 @@
import {Suspense, useRef, useState} from 'react'
import {View} from 'react-native'
import type ViewShot from 'react-native-view-shot'
import {requestPermissionsAsync, saveToLibraryAsync} from 'expo-media-library'
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
import {createAssetAsync} from 'expo-media-library'
import * as Sharing from 'expo-sharing'
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {msg} from '@lingui/core/macro'
@@ -59,9 +60,7 @@ export function QrCodeDialog({
const onSavePress = async () => {
ref.current?.capture?.().then(async (uri: string) => {
if (IS_NATIVE) {
// Write-only permission - saving the QR image does not require read
// access to the user's photo library.
const res = await requestPermissionsAsync(true)
const res = await requestMediaLibraryPermissionsAsync()
if (!res.granted) {
Toast.show(
@@ -74,9 +73,7 @@ export function QrCodeDialog({
// Incase of a FS failure, don't crash the app
try {
// saveToLibraryAsync writes without reading the asset back, so it
// works with the add-only permission on iOS (APP-2374)
await saveToLibraryAsync(`file://${uri}`)
await createAssetAsync(`file://${uri}`)
} catch (e: unknown) {
Toast.show(_(msg`An error occurred while saving the QR code!`), {
type: 'error',
+1 -3
View File
@@ -23,7 +23,6 @@ export function Text({
title,
dataSet,
numberOfLines,
allowFontScaling = true,
...rest
}: TextProps) {
const {fonts, flags} = useAlf()
@@ -37,7 +36,7 @@ export function Text({
style,
],
{
fontScale: allowFontScaling ? fonts.scaleMultiplier : 1,
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags,
},
@@ -58,7 +57,6 @@ export function Text({
numberOfLines,
style: s,
dataSet: Object.assign({tooltip: title}, dataSet || {}),
allowFontScaling,
...rest,
}
@@ -60,7 +60,6 @@ export function FindContactsBannerNUX() {
a.self_end,
a.mt_sm,
]}
useAppleWebpCodec
/>
<View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}>
<Text
@@ -27,7 +27,6 @@ export function ContactsHeroImage() {
alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)}
useAppleWebpCodec
/>
</View>
)
@@ -514,7 +514,11 @@ function ExistingChatCard({
]}>
<ProfileCard.Header>
{convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size={40} />
<AvatarBubbles
profiles={convo.members}
size={40}
moderationOpts={moderationOpts}
/>
) : (
<ProfileCard.Avatar
profile={convo.primaryMember}
@@ -663,6 +667,7 @@ function SearchInput({
/>
<TextInput
// @ts-ignore bottom sheet input types issue — esb
ref={inputRef}
placeholder={l`Search`}
value={value}

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