Merge remote-tracking branch 'origin/main' into samuel/modern-list-dialog
* origin/main: (165 commits) Bump vite from 8.0.12 to 8.0.16 in /bskyembed (#10912) Nightly source-language update Fix over-eager URL faceting in DMs composer (#10877) [Chat] Hide unread state for selected chat list item (#10910) Use pnpm 11.7.0 (#10900) fix: truncate long display names in group chat member picker (#10899) (#10901) [Chat] Fix member count in SearchablePeopleList (#10909) [Chat] fix: enforce convo-present invariant for active convo states (#10906) Fix scroll edge effects on iOS 27 (#10907) Nightly source-language update Add long-press menu to Chat tab for marking all as read (#10890) Nightly source-language update Update OTA docs (#10895) Fix pluralization for group chat member counts (#10896) Fix chat firehose event handling and error recovery (#10884) [Chat] Restructure MessageItem to prevent alignment issues (#10888) [Chat] Fix block dialog overflow issues (#10887) Fix invite card avatar pile not reflecting member count (#10891) [Chat] Change chat invite border color to low (#10886) [Chat] Hide blocked accounts' reactions in chats (#10889) ...
@@ -0,0 +1,63 @@
|
|||||||
|
You are an experienced senior React Native engineer reviewing a pull
|
||||||
|
request in the Bluesky Social app — a cross-platform (iOS, Android, Web)
|
||||||
|
React Native + Expo application. Read the repo's CLAUDE.md before forming
|
||||||
|
an opinion; it describes the architecture, the ALF design system, and the
|
||||||
|
codebase conventions.
|
||||||
|
|
||||||
|
Your audience is other senior engineers. Write peer-to-peer, not
|
||||||
|
teacher-to-junior. Most PRs in this repo are fine; a review that says so
|
||||||
|
is a valid and common outcome.
|
||||||
|
|
||||||
|
Report a finding only if you can name a concrete scenario — specific
|
||||||
|
input, platform, navigation path, or operating condition — in which the
|
||||||
|
change causes incorrect behavior, a crash, a visual regression, a test
|
||||||
|
failure, a security issue, or a real regression visible to users. Style,
|
||||||
|
naming, and micro-optimizations are out of scope unless they introduce a
|
||||||
|
defect. Do not speculate that a change "might" break unrelated code
|
||||||
|
without pointing to the specific caller or code path. Do not repeat what
|
||||||
|
the diff does.
|
||||||
|
|
||||||
|
Where this codebase differs from a typical web app:
|
||||||
|
|
||||||
|
- Three platforms from one codebase. Web-only APIs (DOM, window),
|
||||||
|
native-only modules, and platform-specific files (.web.tsx, .ios.tsx,
|
||||||
|
.android.tsx) are common sources of single-platform breakage. When a
|
||||||
|
change touches shared code, consider all three targets.
|
||||||
|
- User-facing strings must go through Lingui (the `Trans` macro /
|
||||||
|
`useLingui`). Hardcoded English strings in UI are a finding. Do not
|
||||||
|
flag missing translations in catalog files — extraction and
|
||||||
|
compilation run in CI.
|
||||||
|
- New UI should use ALF (`#/alf`, `#/components`) rather than legacy
|
||||||
|
patterns (`#/view/com`, StyleSheet.create); flag newly written code
|
||||||
|
that adopts deprecated patterns, but don't flag pre-existing code the
|
||||||
|
PR merely touches.
|
||||||
|
- Server state lives in TanStack Query under src/state/queries. Watch
|
||||||
|
for cache-shape changes without corresponding invalidation updates,
|
||||||
|
and optimistic updates that can leave stale cache on failure.
|
||||||
|
- List rendering is performance-critical (the main feed). Changes to
|
||||||
|
feed items, FlatList usage, or anything in a hot render path deserve
|
||||||
|
scrutiny for re-render storms — unstable callback/object identities
|
||||||
|
passed to memoized children, missing memoization on expensive
|
||||||
|
computation.
|
||||||
|
- Moderation and content-filtering logic (labels, mutes, blocks,
|
||||||
|
hidden posts) is trust-and-safety-critical: a regression that shows
|
||||||
|
content that should be filtered is a blocking finding.
|
||||||
|
- Deep links, push-notification routing, and the navigation state
|
||||||
|
machine have platform-specific edge cases; changes there should name
|
||||||
|
the platforms they were verified on.
|
||||||
|
- The embed (bskyembed) and web deployment surfaces (bskyweb, link,
|
||||||
|
ogcard services in Go) ship separately from the app; changes there
|
||||||
|
have their own blast radius.
|
||||||
|
|
||||||
|
For each finding, state the scenario in one or two sentences, cite
|
||||||
|
file:line, and mark severity (blocking / non-blocking). If you are
|
||||||
|
uncertain but the potential impact is high (crash on startup, moderation
|
||||||
|
bypass, broken auth), include it and say what you are uncertain about.
|
||||||
|
Otherwise, prefer silence over guessing.
|
||||||
|
|
||||||
|
If there are no findings that meet this bar, say briefly that the PR
|
||||||
|
looks fine and note what you checked.
|
||||||
|
|
||||||
|
Post your review as a single top-level PR comment. Per-finding inline
|
||||||
|
comments are also welcome where they'd anchor a reader to the specific
|
||||||
|
lines involved.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: claude-mention
|
||||||
|
|
||||||
|
# @claude mention handler on PR conversation comments, inline review
|
||||||
|
# comments, and review bodies. See the header comment in
|
||||||
|
# claude-review.yml for why this is self-contained rather than calling
|
||||||
|
# the org reusable workflows.
|
||||||
|
#
|
||||||
|
# Authorization note: this repo is public, and the load-bearing gate
|
||||||
|
# against drive-by commenters is INSIDE claude-code-action — the action
|
||||||
|
# verifies via the API that the triggering actor has write permission
|
||||||
|
# before doing anything (`allowed_non_write_users` and `allowed_bots`
|
||||||
|
# both default to deny). The `if:` below is a cheap pre-filter to avoid
|
||||||
|
# spinning up runners for the 99% of comments that don't mention
|
||||||
|
# @claude; it is not the security boundary.
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
actions: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mention:
|
||||||
|
# Skip comments/reviews that don't mention @claude, non-PR issue
|
||||||
|
# comments, and events from claude[bot] itself (its review prose
|
||||||
|
# often quotes "@claude" and must not retrigger the workflow).
|
||||||
|
if: >
|
||||||
|
(
|
||||||
|
(github.event_name == 'issue_comment' &&
|
||||||
|
github.event.issue.pull_request != null &&
|
||||||
|
contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' &&
|
||||||
|
contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' &&
|
||||||
|
contains(github.event.review.body, '@claude'))
|
||||||
|
) &&
|
||||||
|
github.actor != 'claude[bot]'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: claude-mention-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Configure AWS credentials (OIDC)
|
||||||
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
|
with:
|
||||||
|
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||||
|
aws-region: us-east-2
|
||||||
|
|
||||||
|
- name: Claude
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
use_bedrock: 'true'
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
track_progress: true
|
||||||
|
claude_args: |
|
||||||
|
--model global.anthropic.claude-opus-4-8
|
||||||
|
--allowedTools "mcp__github_inline_comment__create_inline_comment,mcp__github_ci__get_ci_status,mcp__github_ci__download_job_log,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: claude-review
|
||||||
|
|
||||||
|
# Automatic Claude review on PR creation/update, via Bedrock (OIDC, no
|
||||||
|
# long-lived tokens). Self-contained: this intentionally uses upstream
|
||||||
|
# claude-code-action defaults rather than the org reusable workflows in
|
||||||
|
# bluesky-social/.github (which a public repo cannot call, and whose
|
||||||
|
# customizations added no value over upstream).
|
||||||
|
#
|
||||||
|
# Review guidance lives in .github/claude-review-prompt.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
actions: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
review:
|
||||||
|
# Internal PRs only. This repo is public: fork PRs are the bulk of
|
||||||
|
# community traffic and MUST NOT trigger reviews (no Bedrock spend on
|
||||||
|
# unvetted code, and fork PRs can't mint the OIDC token anyway —
|
||||||
|
# belt-and-braces with this explicit guard). Branch PRs can only be
|
||||||
|
# created by people with write access, i.e. org members.
|
||||||
|
# Bot-authored PRs (dependabot, changesets) are also skipped.
|
||||||
|
if: >
|
||||||
|
github.event.pull_request.draft == false &&
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||||
|
github.event.pull_request.user.type != 'Bot'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: claude-review-${{ github.repository }}-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Configure AWS credentials (OIDC)
|
||||||
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
|
with:
|
||||||
|
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||||
|
aws-region: us-east-2
|
||||||
|
|
||||||
|
- name: Claude review
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
use_bedrock: 'true'
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
track_progress: true
|
||||||
|
claude_args: |
|
||||||
|
--model global.anthropic.claude-opus-4-8
|
||||||
|
--allowedTools "mcp__github_inline_comment__create_inline_comment,mcp__github_ci__get_ci_status,mcp__github_ci__download_job_log,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||||
|
prompt: |
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
|
Read .github/claude-review-prompt.md in the checked-out repo
|
||||||
|
and review this pull request following its guidance.
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
|
||||||
|
|
||||||
# NOTE(sfn): we can add a custom system prompt here
|
|
||||||
|
|
||||||
claude_args: |
|
|
||||||
--model claude-opus-4-7
|
|
||||||
@@ -20,6 +20,7 @@ jobs:
|
|||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: bskyweb/go.mod
|
go-version-file: bskyweb/go.mod
|
||||||
|
cache-dependency-path: bskyweb/go.sum
|
||||||
- name: Dummy Static Files
|
- name: Dummy Static Files
|
||||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||||
- name: Check
|
- name: Check
|
||||||
@@ -37,6 +38,7 @@ jobs:
|
|||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: bskyweb/go.mod
|
go-version-file: bskyweb/go.mod
|
||||||
|
cache-dependency-path: bskyweb/go.sum
|
||||||
- name: Dummy Static Files
|
- name: Dummy Static Files
|
||||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||||
- name: Lint
|
- name: Lint
|
||||||
|
|||||||
@@ -57,11 +57,7 @@ jobs:
|
|||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: pnpm install
|
- name: pnpm install
|
||||||
uses: Wandalen/wretry.action@master
|
run: pnpm install --frozen-lockfile
|
||||||
with:
|
|
||||||
command: pnpm install --frozen-lockfile
|
|
||||||
attempt_limit: 3
|
|
||||||
attempt_delay: 2000
|
|
||||||
- name: Check & compile i18n
|
- name: Check & compile i18n
|
||||||
run: pnpm intl:build
|
run: pnpm intl:build
|
||||||
- name: Lint checks
|
- name: Lint checks
|
||||||
@@ -99,11 +95,7 @@ jobs:
|
|||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: pnpm install
|
- name: pnpm install
|
||||||
uses: Wandalen/wretry.action@master
|
run: pnpm install --frozen-lockfile
|
||||||
with:
|
|
||||||
command: pnpm install --frozen-lockfile
|
|
||||||
attempt_limit: 3
|
|
||||||
attempt_delay: 2000
|
|
||||||
- name: Check & compile i18n
|
- name: Check & compile i18n
|
||||||
run: pnpm intl:build
|
run: pnpm intl:build
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
|||||||
@@ -26,11 +26,7 @@ jobs:
|
|||||||
node-version-file: package.json
|
node-version-file: package.json
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: pnpm install
|
- name: pnpm install
|
||||||
uses: Wandalen/wretry.action@master
|
run: pnpm install --frozen-lockfile
|
||||||
with:
|
|
||||||
command: pnpm install --frozen-lockfile
|
|
||||||
attempt_limit: 3
|
|
||||||
attempt_delay: 2000
|
|
||||||
- name: Extract language strings
|
- name: Extract language strings
|
||||||
run: pnpm intl:extract
|
run: pnpm intl:extract
|
||||||
- name: Create commit
|
- name: Create commit
|
||||||
|
|||||||
@@ -34,12 +34,8 @@ jobs:
|
|||||||
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
|
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
|
||||||
|
|
||||||
- name: pnpm install
|
- name: pnpm install
|
||||||
uses: Wandalen/wretry.action@master
|
# Fine to skip scripts since we don't run any code
|
||||||
with:
|
run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
|
||||||
# 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
|
- name: Verify pnpm-lock.yaml
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -133,3 +133,4 @@ bskyweb/static/media/*.svg
|
|||||||
|
|
||||||
# superpowers plugin plans/specs — local-only workspace
|
# superpowers plugin plans/specs — local-only workspace
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
.claude/worktrees
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ WORKDIR /app
|
|||||||
COPY ./bskyogcard/package.json ./
|
COPY ./bskyogcard/package.json ./
|
||||||
COPY ./bskyogcard/pnpm-lock.yaml ./
|
COPY ./bskyogcard/pnpm-lock.yaml ./
|
||||||
COPY ./bskyogcard/pnpm-workspace.yaml ./
|
COPY ./bskyogcard/pnpm-workspace.yaml ./
|
||||||
RUN npm install --global pnpm@11.1.3
|
RUN npm install --global pnpm@11.5.2
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY ./bskyogcard ./
|
COPY ./bskyogcard ./
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
|
|||||||
RUN \. "$NVM_DIR/nvm.sh" && \
|
RUN \. "$NVM_DIR/nvm.sh" && \
|
||||||
nvm install $NODE_VERSION && \
|
nvm install $NODE_VERSION && \
|
||||||
nvm use $NODE_VERSION && \
|
nvm use $NODE_VERSION && \
|
||||||
npm install --global pnpm@11.5.0 && \
|
npm install --global pnpm@11.7.0 && \
|
||||||
pnpm install --frozen-lockfile && \
|
pnpm install --frozen-lockfile && \
|
||||||
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
||||||
pnpm intl:build && \
|
pnpm intl:build && \
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
|
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
|
||||||
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
||||||
|
|
||||||
|
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
|
||||||
import {
|
import {
|
||||||
downloadAndResize,
|
downloadAndResize,
|
||||||
type DownloadAndResizeOpts,
|
type DownloadAndResizeOpts,
|
||||||
getResizedDimensions,
|
|
||||||
} from '../../src/lib/media/manip'
|
} from '../../src/lib/media/manip'
|
||||||
|
import {getResizedDimensions} from '../../src/lib/media/util'
|
||||||
|
|
||||||
const mockResizedImage = {
|
const mockResizedImage = {
|
||||||
path: 'file://resized-image.jpg',
|
path: 'file://resized-image.jpg',
|
||||||
@@ -41,10 +42,8 @@ describe('downloadAndResize', () => {
|
|||||||
|
|
||||||
const opts: DownloadAndResizeOpts = {
|
const opts: DownloadAndResizeOpts = {
|
||||||
uri: 'https://example.com/image.jpg',
|
uri: 'https://example.com/image.jpg',
|
||||||
width: 100,
|
maxDimension: 2000,
|
||||||
height: 100,
|
|
||||||
maxSize: 500000,
|
maxSize: 500000,
|
||||||
mode: 'cover',
|
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +59,11 @@ describe('downloadAndResize', () => {
|
|||||||
|
|
||||||
// First time it gets called is to get dimensions
|
// First time it gets called is to get dimensions
|
||||||
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
|
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
|
||||||
|
// The mocked source image is 100x100, below maxDimension, so it is not
|
||||||
|
// downsized.
|
||||||
expect(manipulateAsync).toHaveBeenCalledWith(
|
expect(manipulateAsync).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
[{resize: {height: opts.height, width: opts.width}}],
|
[{resize: {height: 100, width: 100}}],
|
||||||
{format: SaveFormat.JPEG, compress: 1.0},
|
{format: SaveFormat.JPEG, compress: 1.0},
|
||||||
)
|
)
|
||||||
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
|
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
|
||||||
@@ -73,10 +74,8 @@ describe('downloadAndResize', () => {
|
|||||||
it('should return undefined for invalid URI', async () => {
|
it('should return undefined for invalid URI', async () => {
|
||||||
const opts: DownloadAndResizeOpts = {
|
const opts: DownloadAndResizeOpts = {
|
||||||
uri: 'invalid-uri',
|
uri: 'invalid-uri',
|
||||||
width: 100,
|
maxDimension: 2000,
|
||||||
height: 100,
|
|
||||||
maxSize: 500000,
|
maxSize: 500000,
|
||||||
mode: 'cover',
|
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,13 +89,19 @@ describe('downloadAndResize', () => {
|
|||||||
width: 1200,
|
width: 1200,
|
||||||
height: 1000,
|
height: 1000,
|
||||||
}
|
}
|
||||||
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
|
const resizedDimensionsOne = getResizedDimensions(
|
||||||
|
initialDimensionsOne,
|
||||||
|
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||||
|
)
|
||||||
|
|
||||||
const initialDimensionsTwo = {
|
const initialDimensionsTwo = {
|
||||||
width: 1000,
|
width: 1000,
|
||||||
height: 1200,
|
height: 1200,
|
||||||
}
|
}
|
||||||
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
|
const resizedDimensionsTwo = getResizedDimensions(
|
||||||
|
initialDimensionsTwo,
|
||||||
|
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||||
|
)
|
||||||
|
|
||||||
expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
|
expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
|
||||||
expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
|
expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
|
||||||
@@ -107,13 +112,19 @@ describe('downloadAndResize', () => {
|
|||||||
width: 3000,
|
width: 3000,
|
||||||
height: 1500,
|
height: 1500,
|
||||||
}
|
}
|
||||||
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
|
const resizedDimensionsOne = getResizedDimensions(
|
||||||
|
initialDimensionsOne,
|
||||||
|
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||||
|
)
|
||||||
|
|
||||||
const initialDimensionsTwo = {
|
const initialDimensionsTwo = {
|
||||||
width: 2000,
|
width: 2000,
|
||||||
height: 4000,
|
height: 4000,
|
||||||
}
|
}
|
||||||
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
|
const resizedDimensionsTwo = getResizedDimensions(
|
||||||
|
initialDimensionsTwo,
|
||||||
|
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||||
|
)
|
||||||
|
|
||||||
expect(resizedDimensionsOne).toEqual({
|
expect(resizedDimensionsOne).toEqual({
|
||||||
width: 2000,
|
width: 2000,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {describe, expect, it} from '@jest/globals'
|
import {describe, expect, it} from '@jest/globals'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getChatInviteCodeFromUrl,
|
||||||
isPossiblyAUrl,
|
isPossiblyAUrl,
|
||||||
isTrustedUrl,
|
isTrustedUrl,
|
||||||
linkRequiresWarning,
|
linkRequiresWarning,
|
||||||
@@ -178,3 +179,47 @@ describe('isTrustedUrl', () => {
|
|||||||
expect(output).toEqual(expected)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ module.exports = function (_config) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'@mozzius/expo-dynamic-app-icon',
|
'@bsky.app/expo-dynamic-app-icon',
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Default set
|
* Default set
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -13,7 +13,7 @@
|
|||||||
"format": "prettier -w src"
|
"format": "prettier -w src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.15.25",
|
"@atproto/api": "0.20.11",
|
||||||
"preact": "^10.4.8"
|
"preact": "^10.4.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"terser": "^5.43.1",
|
"terser": "^5.43.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.16",
|
||||||
"vite-bundle-analyzer": "^1.3.8"
|
"vite-bundle-analyzer": "^1.3.8"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AppBskyEmbedExternal,
|
AppBskyEmbedExternal,
|
||||||
|
AppBskyEmbedGallery,
|
||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
@@ -43,6 +44,11 @@ export function Embed({
|
|||||||
return <ImageEmbed content={content} labelInfo={labelInfo} />
|
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
|
// Case 2: External link
|
||||||
if (AppBskyEmbedExternal.isView(content)) {
|
if (AppBskyEmbedExternal.isView(content)) {
|
||||||
return <ExternalEmbed content={content} labelInfo={labelInfo} />
|
return <ExternalEmbed content={content} labelInfo={labelInfo} />
|
||||||
@@ -230,6 +236,8 @@ function Info({children}: {children: ComponentChildren}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GridImage = {thumb: string; alt: string}
|
||||||
|
|
||||||
function ImageEmbed({
|
function ImageEmbed({
|
||||||
content,
|
content,
|
||||||
labelInfo,
|
labelInfo,
|
||||||
@@ -240,20 +248,45 @@ function ImageEmbed({
|
|||||||
if (labelInfo) {
|
if (labelInfo) {
|
||||||
return <Info>{labelInfo}</Info>
|
return <Info>{labelInfo}</Info>
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
<ImageGrid
|
||||||
|
images={content.images.map(i => ({thumb: i.thumb, alt: i.alt}))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
switch (content.images.length) {
|
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
|
||||||
case 1:
|
case 1:
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={content.images[0].thumb}
|
src={images[0].thumb}
|
||||||
alt={content.images[0].alt}
|
alt={images[0].alt}
|
||||||
className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]"
|
className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 2:
|
case 2:
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
|
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
|
||||||
{content.images.map((image, i) => (
|
{images.map((image, i) => (
|
||||||
<img
|
<img
|
||||||
key={i}
|
key={i}
|
||||||
src={image.thumb}
|
src={image.thumb}
|
||||||
@@ -268,13 +301,13 @@ function ImageEmbed({
|
|||||||
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
|
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
|
||||||
<div className="flex-1 aspect-square">
|
<div className="flex-1 aspect-square">
|
||||||
<img
|
<img
|
||||||
src={content.images[0].thumb}
|
src={images[0].thumb}
|
||||||
alt={content.images[0].alt}
|
alt={images[0].alt}
|
||||||
className="w-full h-full object-cover rounded-sm"
|
className="w-full h-full object-cover rounded-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 flex-1">
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
{content.images.slice(1).map((image, i) => (
|
{images.slice(1).map((image, i) => (
|
||||||
<img
|
<img
|
||||||
key={i}
|
key={i}
|
||||||
src={image.thumb}
|
src={image.thumb}
|
||||||
@@ -285,21 +318,36 @@ function ImageEmbed({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
case 4:
|
default: {
|
||||||
|
const remaining = images.length - 4
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
|
<div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
|
||||||
{content.images.map((image, i) => (
|
{images.slice(0, 4).map((image, i) => {
|
||||||
<img
|
const isOverflowCell = i === 3 && remaining > 0
|
||||||
key={i}
|
return (
|
||||||
src={image.thumb}
|
<div
|
||||||
alt={image.alt}
|
key={i}
|
||||||
className="aspect-[3/2] w-full object-cover rounded-sm"
|
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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
default:
|
}
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ export function Post({thread}: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||||
if (!record) return 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
|
||||||
|
|
||||||
const rt = new RichText({
|
const rt = new RichText({
|
||||||
text: record.text,
|
text: record.text,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"devEngines": {
|
"devEngines": {
|
||||||
"packageManager": {
|
"packageManager": {
|
||||||
"name": "pnpm",
|
"name": "pnpm",
|
||||||
"version": "11.1.3",
|
"version": "11.5.2",
|
||||||
"onFail": "warn"
|
"onFail": "warn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.20.6",
|
"@atproto/api": "0.20.13",
|
||||||
"@atproto/common": "^0.6.1",
|
"@atproto/common": "^0.6.1",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
|||||||
@@ -7,52 +7,52 @@ importers:
|
|||||||
configDependencies: {}
|
configDependencies: {}
|
||||||
packageManagerDependencies:
|
packageManagerDependencies:
|
||||||
'@pnpm/exe':
|
'@pnpm/exe':
|
||||||
specifier: 11.1.3
|
specifier: 11.5.2
|
||||||
version: 11.1.3
|
version: 11.5.2
|
||||||
pnpm:
|
pnpm:
|
||||||
specifier: 11.1.3
|
specifier: 11.5.2
|
||||||
version: 11.1.3
|
version: 11.5.2
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@pnpm/exe@11.1.3':
|
'@pnpm/exe@11.5.2':
|
||||||
resolution: {integrity: sha512-J6bSMpZlHVUKuMKtPT/+lrFPpvBiOIgk6HnNC3vmoM/fsMgFhao6ITFwdsUMzdCl2qHf9cnVYA4ZBdsGmVUnpg==}
|
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@pnpm/linux-arm64@11.1.3':
|
'@pnpm/linux-arm64@11.5.2':
|
||||||
resolution: {integrity: sha512-sz3fc0hSqguk2eGe/InelpBD3LP82MzF+8pLzDpYNGuhasGY+VWkuxEo02HczQYRVXTsbpZVepk+Qs2CONc04Q==}
|
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@pnpm/linux-x64@11.1.3':
|
'@pnpm/linux-x64@11.5.2':
|
||||||
resolution: {integrity: sha512-UadJh5fJZWa47OtdZTWLKWDj4z5WpZFB5pS2wOh/kfKypUmQyjmbOMClP7/yJGS2ZtrSjRgYjIEWAjndkWsMaA==}
|
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@pnpm/linuxstatic-arm64@11.1.3':
|
'@pnpm/linuxstatic-arm64@11.5.2':
|
||||||
resolution: {integrity: sha512-lWmGr96w+VrIRVsEfTXROB3GeQNxrX2Hy32j3USHr6WlqmpH1i7YjavJGpoXeVZbb77fCFjNFAtsJzGXSgy/Og==}
|
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@pnpm/linuxstatic-x64@11.1.3':
|
'@pnpm/linuxstatic-x64@11.5.2':
|
||||||
resolution: {integrity: sha512-I74GDBOPbr5TXgob3ct4hv6BQtLGdsjGJrII2qNl/e2xYHXekjIWE4Eh7RGN4C7xu+BZYKtvnOOr149yR7KxUw==}
|
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@pnpm/macos-arm64@11.1.3':
|
'@pnpm/macos-arm64@11.5.2':
|
||||||
resolution: {integrity: sha512-nWn155BVa54iNyg4iolVhjMtqumXHPh8ul5CFjQFJXdwgr9MRUgc5EUBML1+7mS8C/7Wcex5HUgn/w3fliHCsQ==}
|
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@pnpm/win-arm64@11.1.3':
|
'@pnpm/win-arm64@11.5.2':
|
||||||
resolution: {integrity: sha512-4u6PQL7/WgwdweC2ZJcdCzhM1koRFG5ofcUSrIVsdyo9ePfGq2D9p1rkZbiLEfIGvl3GKOsfMB5DRTgs8es4AQ==}
|
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@pnpm/win-x64@11.1.3':
|
'@pnpm/win-x64@11.5.2':
|
||||||
resolution: {integrity: sha512-JmhH7ljJ3MWjvWFz/YHbu/27ISCNLZsQb1JG+ib3uHlfUwIJuF6BudXYsbS5Uu0o7xUvC9sPPm+Uho4Or6R3vA==}
|
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@@ -116,45 +116,45 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
pnpm@11.1.3:
|
pnpm@11.5.2:
|
||||||
resolution: {integrity: sha512-yFNX/hfKEt0j3XBxgiZm39fjy3b+IU4zcLXqL7NPKiMRhVCbY+cX880KyzjdP42CvNXoFyQArmeLcOpPvtCJbQ==}
|
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
|
||||||
engines: {node: '>=22.13'}
|
engines: {node: '>=22.13'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@pnpm/exe@11.1.3':
|
'@pnpm/exe@11.5.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@reflink/reflink': 0.1.19
|
'@reflink/reflink': 0.1.19
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@pnpm/linux-arm64': 11.1.3
|
'@pnpm/linux-arm64': 11.5.2
|
||||||
'@pnpm/linux-x64': 11.1.3
|
'@pnpm/linux-x64': 11.5.2
|
||||||
'@pnpm/linuxstatic-arm64': 11.1.3
|
'@pnpm/linuxstatic-arm64': 11.5.2
|
||||||
'@pnpm/linuxstatic-x64': 11.1.3
|
'@pnpm/linuxstatic-x64': 11.5.2
|
||||||
'@pnpm/macos-arm64': 11.1.3
|
'@pnpm/macos-arm64': 11.5.2
|
||||||
'@pnpm/win-arm64': 11.1.3
|
'@pnpm/win-arm64': 11.5.2
|
||||||
'@pnpm/win-x64': 11.1.3
|
'@pnpm/win-x64': 11.5.2
|
||||||
|
|
||||||
'@pnpm/linux-arm64@11.1.3':
|
'@pnpm/linux-arm64@11.5.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linux-x64@11.1.3':
|
'@pnpm/linux-x64@11.5.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linuxstatic-arm64@11.1.3':
|
'@pnpm/linuxstatic-arm64@11.5.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linuxstatic-x64@11.1.3':
|
'@pnpm/linuxstatic-x64@11.5.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/macos-arm64@11.1.3':
|
'@pnpm/macos-arm64@11.5.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/win-arm64@11.1.3':
|
'@pnpm/win-arm64@11.5.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/win-x64@11.1.3':
|
'@pnpm/win-x64@11.5.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||||
@@ -194,7 +194,7 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
pnpm@11.1.3: {}
|
pnpm@11.5.2: {}
|
||||||
|
|
||||||
---
|
---
|
||||||
lockfileVersion: '9.0'
|
lockfileVersion: '9.0'
|
||||||
@@ -208,8 +208,8 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/api':
|
'@atproto/api':
|
||||||
specifier: 0.20.6
|
specifier: 0.20.13
|
||||||
version: 0.20.6
|
version: 0.20.13
|
||||||
'@atproto/common':
|
'@atproto/common':
|
||||||
specifier: ^0.6.1
|
specifier: ^0.6.1
|
||||||
version: 0.6.1
|
version: 0.6.1
|
||||||
@@ -259,8 +259,8 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@atproto/api@0.20.6':
|
'@atproto/api@0.20.13':
|
||||||
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
|
resolution: {integrity: sha512-AN7NTUsygHXnXs5Cnc6v7/BAjQydwAhE0P8hL21RpIAB4fmTksRKTU3TV//wPg4fk9ESsfNwGIAlwoybbyoZKg==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/common-web@0.5.0':
|
'@atproto/common-web@0.5.0':
|
||||||
@@ -1154,7 +1154,7 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@atproto/api@0.20.6':
|
'@atproto/api@0.20.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/common-web': 0.5.0
|
'@atproto/common-web': 0.5.0
|
||||||
'@atproto/lexicon': 0.7.1
|
'@atproto/lexicon': 0.7.1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert'
|
import assert from 'node:assert'
|
||||||
|
|
||||||
import {type ChatBskyGroupDefs} from '@atproto/api'
|
import {ChatBskyGroupDefs} from '@atproto/api'
|
||||||
import resvg from '@resvg/resvg-js'
|
import resvg from '@resvg/resvg-js'
|
||||||
import {type Express} from 'express'
|
import {type Express} from 'express'
|
||||||
import satori from 'satori'
|
import satori from 'satori'
|
||||||
@@ -32,7 +32,7 @@ export default function (ctx: AppContext, app: Express) {
|
|||||||
codes: [code],
|
codes: [code],
|
||||||
})
|
})
|
||||||
const found = result.data.joinLinkPreviews[0]
|
const found = result.data.joinLinkPreviews[0]
|
||||||
if (!found) {
|
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(found)) {
|
||||||
return res.status(404).end('not found')
|
return res.status(404).end('not found')
|
||||||
}
|
}
|
||||||
preview = found
|
preview = found
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import (
|
|||||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helpers for extracting Open Graph metadata from post embeds. These feed
|
// Helpers for extracting Open Graph metadata from post embeds. og:video
|
||||||
// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in
|
// shares findVideoEmbed with the JSON-LD VideoObject path in jsonld.go so
|
||||||
// jsonld.go. og:video has its own path because JSON-LD does not yet emit
|
// the two outputs cannot drift.
|
||||||
// VideoObject (deferred — needs `duration` from the appview).
|
|
||||||
|
|
||||||
// videoMeta holds og:video meta tag data.
|
// videoMeta holds og:video meta tag data.
|
||||||
type videoMeta struct {
|
type videoMeta struct {
|
||||||
@@ -21,17 +20,7 @@ type videoMeta struct {
|
|||||||
// extractVideoMeta returns og:video metadata, or the zero value if there's
|
// 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.
|
// no video embed. Respects embedHidden so og:video and og:image stay in sync.
|
||||||
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
|
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
|
||||||
if pv == nil || pv.Embed == nil || embedHidden {
|
v := findVideoEmbed(pv, 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 == "" {
|
if v == nil || v.Playlist == "" {
|
||||||
return videoMeta{}
|
return videoMeta{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type discussionForumPosting struct {
|
|||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Image []string `json:"image,omitempty"`
|
Image []string `json:"image,omitempty"`
|
||||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||||
|
Video *videoObject `json:"video,omitempty"`
|
||||||
DatePublished string `json:"datePublished,omitempty"`
|
DatePublished string `json:"datePublished,omitempty"`
|
||||||
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
|
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
|
||||||
CommentCount *int64 `json:"commentCount,omitempty"`
|
CommentCount *int64 `json:"commentCount,omitempty"`
|
||||||
@@ -67,6 +68,20 @@ type discussionForumPosting struct {
|
|||||||
SharedContent *sharedContent `json:"sharedContent,omitempty"`
|
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
|
// comment is the schema.org Comment shape used in
|
||||||
// DiscussionForumPosting.comment[]. The comment property does not accept
|
// DiscussionForumPosting.comment[]. The comment property does not accept
|
||||||
// DiscussionForumPosting, so replies map to Comment.
|
// DiscussionForumPosting, so replies map to Comment.
|
||||||
@@ -78,6 +93,7 @@ type comment struct {
|
|||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Image []string `json:"image,omitempty"`
|
Image []string `json:"image,omitempty"`
|
||||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||||
|
Video *videoObject `json:"video,omitempty"`
|
||||||
DatePublished string `json:"datePublished,omitempty"`
|
DatePublished string `json:"datePublished,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +143,31 @@ func bskyPostURLFromATURI(handle, atURI string) string {
|
|||||||
return bskyPostURL(handle, parsed.RecordKey().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
|
// bskyProfileURL returns the canonical handle-form profile URL, or "" if
|
||||||
// the handle is unusable.
|
// the handle is unusable.
|
||||||
func bskyProfileURL(handle string) string {
|
func bskyProfileURL(handle string) string {
|
||||||
@@ -136,9 +177,9 @@ func bskyProfileURL(handle string) string {
|
|||||||
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
|
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPostMedia returns thumbnail URLs for the post's image or video
|
// extractPostMedia returns thumbnail URLs for the post's image, gallery,
|
||||||
// embed, byte-identical to what we put in og:image. Callers derive
|
// or video embed, byte-identical to what we put in og:image. Callers
|
||||||
// thumbnailUrl from urls[0].
|
// derive thumbnailUrl from urls[0].
|
||||||
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
|
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
|
||||||
if pv == nil || pv.Embed == nil || embedHidden {
|
if pv == nil || pv.Embed == nil || embedHidden {
|
||||||
return nil
|
return nil
|
||||||
@@ -147,6 +188,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
|
|||||||
if pv.Embed.EmbedImages_View != nil {
|
if pv.Embed.EmbedImages_View != nil {
|
||||||
return imageThumbs(pv.Embed.EmbedImages_View.Images)
|
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 {
|
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
|
||||||
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
|
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
|
||||||
}
|
}
|
||||||
@@ -155,6 +199,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
|
|||||||
if media.EmbedImages_View != nil {
|
if media.EmbedImages_View != nil {
|
||||||
return imageThumbs(media.EmbedImages_View.Images)
|
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 {
|
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
|
||||||
return []string{*media.EmbedVideo_View.Thumbnail}
|
return []string{*media.EmbedVideo_View.Thumbnail}
|
||||||
}
|
}
|
||||||
@@ -174,6 +221,89 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
|
|||||||
return urls
|
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 ""
|
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
|
||||||
// if the embed is blocked / not-found / detached / a non-post record.
|
// if the embed is blocked / not-found / detached / a non-post record.
|
||||||
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
||||||
@@ -363,14 +493,18 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
|
|||||||
thumb = images[0]
|
thumb = images[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
|
||||||
|
postText := postRecordText(pv)
|
||||||
|
|
||||||
node := discussionForumPosting{
|
node := discussionForumPosting{
|
||||||
Type: "DiscussionForumPosting",
|
Type: "DiscussionForumPosting",
|
||||||
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
|
URL: postURL,
|
||||||
Identifier: pv.Uri,
|
Identifier: pv.Uri,
|
||||||
Author: buildAuthor(pv.Author),
|
Author: buildAuthor(pv.Author),
|
||||||
Text: postRecordText(pv),
|
Text: postText,
|
||||||
Image: images,
|
Image: images,
|
||||||
ThumbnailURL: thumb,
|
ThumbnailURL: thumb,
|
||||||
|
Video: buildVideoObject(pv, postURL, postText, embedHidden),
|
||||||
DatePublished: pv.IndexedAt,
|
DatePublished: pv.IndexedAt,
|
||||||
InteractionStat: buildPostStats(pv),
|
InteractionStat: buildPostStats(pv),
|
||||||
}
|
}
|
||||||
@@ -433,14 +567,17 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
|
|||||||
if len(images) > 0 {
|
if len(images) > 0 {
|
||||||
thumb = images[0]
|
thumb = images[0]
|
||||||
}
|
}
|
||||||
|
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
|
||||||
|
postText := postRecordText(pv)
|
||||||
return comment{
|
return comment{
|
||||||
Type: "Comment",
|
Type: "Comment",
|
||||||
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
|
URL: postURL,
|
||||||
Identifier: pv.Uri,
|
Identifier: pv.Uri,
|
||||||
Author: buildAuthor(pv.Author),
|
Author: buildAuthor(pv.Author),
|
||||||
Text: postRecordText(pv),
|
Text: postText,
|
||||||
Image: images,
|
Image: images,
|
||||||
ThumbnailURL: thumb,
|
ThumbnailURL: thumb,
|
||||||
|
Video: buildVideoObject(pv, postURL, postText, embedHidden),
|
||||||
DatePublished: pv.IndexedAt,
|
DatePublished: pv.IndexedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,60 @@ 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.
|
// withVideo adds a video embed with a thumbnail.
|
||||||
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
|
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
|
||||||
return func(pv *appbsky.FeedDefs_PostView) {
|
return func(pv *appbsky.FeedDefs_PostView) {
|
||||||
@@ -299,6 +353,88 @@ 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) {
|
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
||||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
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))
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
|
||||||
@@ -361,6 +497,25 @@ 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) {
|
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
|
||||||
// Includes ", \, newline, </script>, and a unicode char.
|
// Includes ", \, newline, </script>, and a unicode char.
|
||||||
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
||||||
@@ -1049,3 +1204,339 @@ func TestBuildProfileJSONLD_HasPartAuthorReviewedBy(t *testing.T) {
|
|||||||
t.Errorf("verifier identifier wrong: %v", rb[0])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ func run(args []string) {
|
|||||||
Value: ":8100",
|
Value: ":8100",
|
||||||
EnvVars: []string{"HTTP_ADDRESS"},
|
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{
|
&cli.StringFlag{
|
||||||
Name: "link-host",
|
Name: "link-host",
|
||||||
Usage: "scheme, hostname, and port of link service",
|
Usage: "scheme, hostname, and port of link service",
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ func extractJSONLD(t *testing.T, html string) string {
|
|||||||
return strings.TrimSpace(m[1])
|
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) {
|
func TestRenderPost_EmitsJSONLD(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
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)
|
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||||
@@ -98,6 +105,40 @@ 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) {
|
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
||||||
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||||
@@ -209,3 +250,50 @@ func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
|
|||||||
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -35,15 +36,17 @@ import (
|
|||||||
"github.com/labstack/echo-contrib/echoprometheus"
|
"github.com/labstack/echo-contrib/echoprometheus"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
echo *echo.Echo
|
echo *echo.Echo
|
||||||
httpd *http.Server
|
httpd *http.Server
|
||||||
xrpcc *xrpc.Client
|
metricsHttpd *http.Server
|
||||||
chatXrpcc *xrpc.Client
|
xrpcc *xrpc.Client
|
||||||
cfg *Config
|
chatXrpcc *xrpc.Client
|
||||||
|
cfg *Config
|
||||||
|
|
||||||
ipccClient http.Client
|
ipccClient http.Client
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ type Config struct {
|
|||||||
func serve(cctx *cli.Context) error {
|
func serve(cctx *cli.Context) error {
|
||||||
debug := cctx.Bool("debug")
|
debug := cctx.Bool("debug")
|
||||||
httpAddress := cctx.String("http-address")
|
httpAddress := cctx.String("http-address")
|
||||||
|
metricsAddress := cctx.String("metrics-address")
|
||||||
appviewHost := cctx.String("appview-host")
|
appviewHost := cctx.String("appview-host")
|
||||||
chatHost := cctx.String("chat-host")
|
chatHost := cctx.String("chat-host")
|
||||||
ogcardHost := cctx.String("ogcard-host")
|
ogcardHost := cctx.String("ogcard-host")
|
||||||
@@ -88,7 +92,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
Host: appviewHost,
|
Host: appviewHost,
|
||||||
}
|
}
|
||||||
|
|
||||||
// optional client for the chat appview, used by /c/<code> for OG previews.
|
// optional client for the chat appview, used by /chat/<code> for OG previews.
|
||||||
var chatXrpcc *xrpc.Client
|
var chatXrpcc *xrpc.Client
|
||||||
if chatHost != "" {
|
if chatHost != "" {
|
||||||
chatXrpcc = &xrpc.Client{
|
chatXrpcc = &xrpc.Client{
|
||||||
@@ -296,50 +300,50 @@ func serve(cctx *cli.Context) error {
|
|||||||
// generic routes
|
// generic routes
|
||||||
e.GET("/hashtag/:tag", server.WebGeneric)
|
e.GET("/hashtag/:tag", server.WebGeneric)
|
||||||
e.GET("/topic/:topic", server.WebGeneric)
|
e.GET("/topic/:topic", server.WebGeneric)
|
||||||
e.GET("/search", server.WebGeneric)
|
e.GET("/search", server.WebGenericNoindex)
|
||||||
e.GET("/feeds", server.WebGeneric)
|
e.GET("/feeds", server.WebGenericNoindex)
|
||||||
e.GET("/notifications", server.WebGeneric)
|
e.GET("/notifications", server.WebGenericNoindex)
|
||||||
e.GET("/notifications/settings", server.WebGeneric)
|
e.GET("/notifications/settings", server.WebGenericNoindex)
|
||||||
e.GET("/notifications/activity", server.WebGeneric)
|
e.GET("/notifications/activity", server.WebGenericNoindex)
|
||||||
e.GET("/lists", server.WebGeneric)
|
e.GET("/lists", server.WebGenericNoindex)
|
||||||
e.GET("/moderation", server.WebGeneric)
|
e.GET("/moderation", server.WebGenericNoindex)
|
||||||
e.GET("/moderation/modlists", server.WebGeneric)
|
e.GET("/moderation/modlists", server.WebGenericNoindex)
|
||||||
e.GET("/moderation/muted-accounts", server.WebGeneric)
|
e.GET("/moderation/muted-accounts", server.WebGenericNoindex)
|
||||||
e.GET("/moderation/blocked-accounts", server.WebGeneric)
|
e.GET("/moderation/blocked-accounts", server.WebGenericNoindex)
|
||||||
e.GET("/moderation/verification-settings", server.WebGeneric)
|
e.GET("/moderation/verification-settings", server.WebGenericNoindex)
|
||||||
e.GET("/settings", server.WebGeneric)
|
e.GET("/settings", server.WebGenericNoindex)
|
||||||
e.GET("/settings/language", server.WebGeneric)
|
e.GET("/settings/language", server.WebGenericNoindex)
|
||||||
e.GET("/settings/app-passwords", server.WebGeneric)
|
e.GET("/settings/app-passwords", server.WebGenericNoindex)
|
||||||
e.GET("/settings/following-feed", server.WebGeneric)
|
e.GET("/settings/following-feed", server.WebGenericNoindex)
|
||||||
e.GET("/settings/saved-feeds", server.WebGeneric)
|
e.GET("/settings/saved-feeds", server.WebGenericNoindex)
|
||||||
e.GET("/settings/threads", server.WebGeneric)
|
e.GET("/settings/threads", server.WebGenericNoindex)
|
||||||
e.GET("/settings/external-embeds", server.WebGeneric)
|
e.GET("/settings/external-embeds", server.WebGenericNoindex)
|
||||||
e.GET("/settings/accessibility", server.WebGeneric)
|
e.GET("/settings/accessibility", server.WebGenericNoindex)
|
||||||
e.GET("/settings/appearance", server.WebGeneric)
|
e.GET("/settings/appearance", server.WebGenericNoindex)
|
||||||
e.GET("/settings/account", server.WebGeneric)
|
e.GET("/settings/account", server.WebGenericNoindex)
|
||||||
e.GET("/settings/automation-label", server.WebGeneric)
|
e.GET("/settings/automation-label", server.WebGenericNoindex)
|
||||||
e.GET("/settings/privacy-and-security", server.WebGeneric)
|
e.GET("/settings/privacy-and-security", server.WebGenericNoindex)
|
||||||
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
|
e.GET("/settings/privacy-and-security/activity", server.WebGenericNoindex)
|
||||||
e.GET("/settings/content-and-media", server.WebGeneric)
|
e.GET("/settings/content-and-media", server.WebGenericNoindex)
|
||||||
e.GET("/settings/interests", server.WebGeneric)
|
e.GET("/settings/interests", server.WebGenericNoindex)
|
||||||
e.GET("/settings/about", server.WebGeneric)
|
e.GET("/settings/about", server.WebGenericNoindex)
|
||||||
e.GET("/settings/notifications", server.WebGeneric)
|
e.GET("/settings/notifications", server.WebGenericNoindex)
|
||||||
e.GET("/sys/debug", server.WebGeneric)
|
e.GET("/sys/debug", server.WebGenericNoindex)
|
||||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
e.GET("/sys/debug-mod", server.WebGenericNoindex)
|
||||||
e.GET("/sys/log", server.WebGeneric)
|
e.GET("/sys/log", server.WebGenericNoindex)
|
||||||
e.GET("/support", server.WebGeneric)
|
e.GET("/support", server.WebGeneric)
|
||||||
e.GET("/support/privacy", server.WebGeneric)
|
e.GET("/support/privacy", server.WebGeneric)
|
||||||
e.GET("/support/tos", server.WebGeneric)
|
e.GET("/support/tos", server.WebGeneric)
|
||||||
e.GET("/support/community-guidelines", server.WebGeneric)
|
e.GET("/support/community-guidelines", server.WebGeneric)
|
||||||
e.GET("/support/copyright", server.WebGeneric)
|
e.GET("/support/copyright", server.WebGeneric)
|
||||||
e.GET("/intent/compose", server.WebGeneric)
|
e.GET("/intent/compose", server.WebGenericNoindexNofollow)
|
||||||
e.GET("/intent/verify-email", server.WebGeneric)
|
e.GET("/intent/verify-email", server.WebGenericNoindexNofollow)
|
||||||
e.GET("/intent/age-assurance", server.WebGeneric)
|
e.GET("/intent/age-assurance", server.WebGenericNoindexNofollow)
|
||||||
e.GET("/messages", server.WebGeneric)
|
e.GET("/messages", server.WebGenericNoindex)
|
||||||
e.GET("/messages/inbox", server.WebGeneric)
|
e.GET("/messages/inbox", server.WebGenericNoindex)
|
||||||
e.GET("/messages/:conversation", server.WebGeneric)
|
e.GET("/messages/:conversation", server.WebGenericNoindex)
|
||||||
e.GET("/messages/:conversation/settings", server.WebGeneric)
|
e.GET("/messages/:conversation/settings", server.WebGenericNoindex)
|
||||||
e.GET("/messages/:conversation/requests", server.WebGeneric)
|
e.GET("/messages/:conversation/requests", server.WebGenericNoindex)
|
||||||
|
|
||||||
// profile endpoints; only first populates info
|
// profile endpoints; only first populates info
|
||||||
e.GET("/profile/:handleOrDID", server.WebProfile)
|
e.GET("/profile/:handleOrDID", server.WebProfile)
|
||||||
@@ -363,14 +367,14 @@ func serve(cctx *cli.Context) error {
|
|||||||
|
|
||||||
// starter packs
|
// starter packs
|
||||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
e.GET("/starter-pack-short/:code", server.WebGeneric)
|
e.GET("/starter-pack-short/:code", server.WebGenericNoindex)
|
||||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
|
|
||||||
// chat invites
|
// chat invites
|
||||||
e.GET("/c/:code", server.WebChatInvite)
|
e.GET("/chat/:code", server.WebChatInvite)
|
||||||
|
|
||||||
// bookmarks
|
// bookmarks
|
||||||
e.GET("/saved", server.WebGeneric)
|
e.GET("/saved", server.WebGenericNoindex)
|
||||||
|
|
||||||
// ipcc
|
// ipcc
|
||||||
e.GET("/ipcc", server.WebIpCC)
|
e.GET("/ipcc", server.WebIpCC)
|
||||||
@@ -387,6 +391,19 @@ func serve(cctx *cli.Context) error {
|
|||||||
e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl))
|
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.
|
// Start the server.
|
||||||
log.Infof("starting server address=%s", httpAddress)
|
log.Infof("starting server address=%s", httpAddress)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -419,6 +436,24 @@ func serve(cctx *cli.Context) error {
|
|||||||
return nil
|
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) {
|
func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
srv.echo.ServeHTTP(rw, req)
|
srv.echo.ServeHTTP(rw, req)
|
||||||
}
|
}
|
||||||
@@ -429,7 +464,18 @@ func (srv *Server) Shutdown() error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
return srv.httpd.Shutdown(ctx)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTemplateContext returns a new pongo2 context with some default values.
|
// NewTemplateContext returns a new pongo2 context with some default values.
|
||||||
@@ -437,6 +483,8 @@ func (srv *Server) NewTemplateContext() pongo2.Context {
|
|||||||
return pongo2.Context{
|
return pongo2.Context{
|
||||||
"staticCDNHost": srv.cfg.staticCDNHost,
|
"staticCDNHost": srv.cfg.staticCDNHost,
|
||||||
"favicon": fmt.Sprintf("%s/static/favicon.png", srv.cfg.staticCDNHost),
|
"favicon": fmt.Sprintf("%s/static/favicon.png", srv.cfg.staticCDNHost),
|
||||||
|
"noindex": false,
|
||||||
|
"nofollow": false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,10 +537,38 @@ 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
|
// handler for endpoint that have no specific server-side handling
|
||||||
func (srv *Server) WebGeneric(c echo.Context) error {
|
func (srv *Server) WebGeneric(c echo.Context) error {
|
||||||
data := srv.NewTemplateContext()
|
return srv.webGeneric(c, renderOptions{})
|
||||||
return c.Render(http.StatusOK, "base.html", data)
|
}
|
||||||
|
|
||||||
|
// 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})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) WebHome(c echo.Context) error {
|
func (srv *Server) WebHome(c echo.Context) error {
|
||||||
@@ -567,6 +643,8 @@ func (srv *Server) WebPost(c echo.Context) error {
|
|||||||
data["canonicalURL"] = canonicalURL
|
data["canonicalURL"] = canonicalURL
|
||||||
}
|
}
|
||||||
data["requiresAuth"] = true
|
data["requiresAuth"] = true
|
||||||
|
data["noindex"] = true
|
||||||
|
data["nofollow"] = true
|
||||||
data["profileHandle"] = pv.Handle
|
data["profileHandle"] = pv.Handle
|
||||||
if pv.DisplayName != nil {
|
if pv.DisplayName != nil {
|
||||||
data["profileDisplayName"] = *pv.DisplayName
|
data["profileDisplayName"] = *pv.DisplayName
|
||||||
@@ -673,6 +751,7 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
|
|||||||
req := c.Request()
|
req := c.Request()
|
||||||
ctx := req.Context()
|
ctx := req.Context()
|
||||||
data := srv.NewTemplateContext()
|
data := srv.NewTemplateContext()
|
||||||
|
data["noindex"] = true
|
||||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||||
|
|
||||||
code := c.Param("code")
|
code := c.Param("code")
|
||||||
@@ -695,7 +774,7 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
|
|||||||
|
|
||||||
data["title"] = preview.Name
|
data["title"] = preview.Name
|
||||||
if srv.cfg.ogcardHost != "" {
|
if srv.cfg.ogcardHost != "" {
|
||||||
// bskyogcard registers this route as /chat-invite/:code, not /c/:code.
|
// bskyogcard registers this route as /chat-invite/:code, not /chat/:code.
|
||||||
data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code)
|
data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code)
|
||||||
}
|
}
|
||||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||||
@@ -760,6 +839,8 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
data["requiresAuth"] = true
|
data["requiresAuth"] = true
|
||||||
|
data["noindex"] = true
|
||||||
|
data["nofollow"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels, hideReplyLabels); err == nil {
|
if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0
|
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c
|
||||||
github.com/flosch/pongo2/v6 v6.0.0
|
github.com/flosch/pongo2/v6 v6.0.0
|
||||||
github.com/ipfs/go-log v1.0.5
|
github.com/ipfs/go-log v1.0.5
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
|||||||
@@ -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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY=
|
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
|
||||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
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/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=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
|
|||||||
@@ -139,6 +139,7 @@
|
|||||||
<meta name="theme-color">
|
<meta name="theme-color">
|
||||||
<meta name="application-name" content="Bluesky">
|
<meta name="application-name" content="Bluesky">
|
||||||
<meta name="generator" content="bskyweb">
|
<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:site_name" content="Bluesky Social">
|
||||||
<meta property="og:logo" content="{{ favicon }}">
|
<meta property="og:logo" content="{{ favicon }}">
|
||||||
<meta name="twitter:site" content="@bluesky" />
|
<meta name="twitter:site" content="@bluesky" />
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ release or OTA.
|
|||||||
Log in to the EAS CLI with `eas login` and manually set the build numbers to the
|
Log in to the EAS CLI with `eas login` and manually set the build numbers to the
|
||||||
values you found in **Step 1**.
|
values you found in **Step 1**.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If you don’t already have the EAS CLI, you can install it with `pnpm add -g eas-cli`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
> npx eas build:version:set -p ios
|
> npx eas build:version:set -p ios
|
||||||
>
|
>
|
||||||
@@ -114,7 +117,7 @@ To confirm this, as mentioned above, you must completely clear the TestFlight
|
|||||||
build from your device and re-install from the App Store. Then, you'll need to:
|
build from your device and re-install from the App Store. Then, you'll need to:
|
||||||
- Launch the app (or quit and reopen) and wait ~15s for the download to complete
|
- Launch the app (or quit and reopen) and wait ~15s for the download to complete
|
||||||
- Quit and reopen the app
|
- Quit and reopen the app
|
||||||
- Check the `Settings > About` page and confirm the has matches the most recent has on your OTA branch
|
- Check the `Settings > About` page and confirm the hash matches the most recent hash on your OTA branch
|
||||||
|
|
||||||
### 9. Reset build numbers
|
### 9. Reset build numbers
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,113 @@
|
|||||||
{
|
{
|
||||||
|
"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": {
|
"src/Navigation.tsx": {
|
||||||
"@typescript-eslint/no-floating-promises": {
|
"@typescript-eslint/no-floating-promises": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -19,11 +128,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/ageAssurance/util.ts": {
|
|
||||||
"@typescript-eslint/no-floating-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/alf/util/flatten.ts": {
|
"src/alf/util/flatten.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -43,9 +147,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/analytics/PassiveAnalytics.tsx": {
|
"src/analytics/PassiveAnalytics.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"react-hooks/purity": {
|
"react-hooks/purity": {
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
@@ -124,11 +225,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/Button.tsx": {
|
|
||||||
"react-hooks/immutability": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/Composer/index.tsx": {
|
"src/components/Composer/index.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -261,11 +357,6 @@
|
|||||||
"count": 3
|
"count": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/Post/Embed/ExternalEmbed/index.tsx": {
|
|
||||||
"@typescript-eslint/no-floating-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/Post/Embed/ImageEmbed.tsx": {
|
"src/components/Post/Embed/ImageEmbed.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -274,11 +365,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/Post/Embed/StandardSiteEmbed/index.tsx": {
|
|
||||||
"@typescript-eslint/no-floating-promises": {
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
|
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
|
||||||
"@typescript-eslint/no-floating-promises": {
|
"@typescript-eslint/no-floating-promises": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -531,11 +617,6 @@
|
|||||||
"count": 3
|
"count": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dialogs/InAppBrowserConsent.tsx": {
|
|
||||||
"@typescript-eslint/no-floating-promises": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/dialogs/LanguageSelectDialog.tsx": {
|
"src/components/dialogs/LanguageSelectDialog.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -543,7 +624,7 @@
|
|||||||
},
|
},
|
||||||
"src/components/dialogs/LinkWarning.tsx": {
|
"src/components/dialogs/LinkWarning.tsx": {
|
||||||
"@typescript-eslint/no-floating-promises": {
|
"@typescript-eslint/no-floating-promises": {
|
||||||
"count": 2
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dialogs/MutedWords.tsx": {
|
"src/components/dialogs/MutedWords.tsx": {
|
||||||
@@ -621,11 +702,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dms/MessageItem.tsx": {
|
|
||||||
"@typescript-eslint/no-misused-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/forms/DateField/index.web.tsx": {
|
"src/components/forms/DateField/index.web.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -668,14 +744,6 @@
|
|||||||
"count": 2
|
"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": {
|
"src/components/hooks/useLandingEntry.native.ts": {
|
||||||
"react-hooks/set-state-in-effect": {
|
"react-hooks/set-state-in-effect": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -775,11 +843,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/features/liveNow/components/LiveStatusDialog.tsx": {
|
|
||||||
"@typescript-eslint/no-floating-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/features/liveNow/index.tsx": {
|
"src/features/liveNow/index.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 3
|
"count": 3
|
||||||
@@ -811,14 +874,6 @@
|
|||||||
"count": 1
|
"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": {
|
"src/lib/async/retry.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -910,14 +965,6 @@
|
|||||||
"count": 4
|
"count": 4
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/lib/hooks/useOpenLink.ts": {
|
|
||||||
"@typescript-eslint/no-floating-promises": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"@typescript-eslint/require-await": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/lib/hooks/usePermissions.ts": {
|
"src/lib/hooks/usePermissions.ts": {
|
||||||
"@typescript-eslint/no-misused-promises": {
|
"@typescript-eslint/no-misused-promises": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -1372,9 +1419,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
|
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
|
||||||
"@typescript-eslint/no-floating-promises": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-misused-promises": {
|
"@typescript-eslint/no-misused-promises": {
|
||||||
"count": 5
|
"count": 5
|
||||||
}
|
}
|
||||||
@@ -1797,16 +1841,6 @@
|
|||||||
"count": 7
|
"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": {
|
"src/state/queries/my-lists.ts": {
|
||||||
"@typescript-eslint/no-floating-promises": {
|
"@typescript-eslint/no-floating-promises": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -1937,12 +1971,6 @@
|
|||||||
"src/state/session/agent.ts": {
|
"src/state/session/agent.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
|
||||||
"@typescript-eslint/no-floating-promises": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"@typescript-eslint/require-await": {
|
|
||||||
"count": 1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/state/shell/color-mode.tsx": {
|
"src/state/shell/color-mode.tsx": {
|
||||||
@@ -2288,23 +2316,6 @@
|
|||||||
"count": 2
|
"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": {
|
"src/view/com/testing/TestCtrls.e2e.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -2343,9 +2354,6 @@
|
|||||||
"src/view/com/util/Link.tsx": {
|
"src/view/com/util/Link.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
},
|
|
||||||
"@typescript-eslint/no-misused-promises": {
|
|
||||||
"count": 2
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/view/com/util/LoadingPlaceholder.tsx": {
|
"src/view/com/util/LoadingPlaceholder.tsx": {
|
||||||
|
|||||||
@@ -67,7 +67,18 @@ export class GifView extends PureComponent<GifViewProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async playAsync(): Promise<void> {
|
async playAsync(): Promise<void> {
|
||||||
this.videoPlayerRef.current?.play()
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async pauseAsync(): Promise<void> {
|
async pauseAsync(): Promise<void> {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleProp, ViewStyle} from 'react-native'
|
import {type StyleProp, type ViewStyle} from 'react-native'
|
||||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||||
|
|
||||||
import {VisibilityViewProps} from './types'
|
import {type VisibilityViewProps} from './types'
|
||||||
const NativeView: React.ComponentType<{
|
const NativeView: React.ComponentType<{
|
||||||
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
|
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
enabled: Boolean
|
enabled: boolean
|
||||||
style: StyleProp<ViewStyle>
|
style: StyleProp<ViewStyle>
|
||||||
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
|
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.media.MediaMetadataRetriever
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import expo.modules.kotlin.modules.Module
|
import expo.modules.kotlin.modules.Module
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
@@ -13,6 +14,8 @@ import java.io.File
|
|||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
private const val TAG = "ExpoReceiveAndroidIntents"
|
||||||
|
|
||||||
enum class AttachmentType {
|
enum class AttachmentType {
|
||||||
IMAGE,
|
IMAGE,
|
||||||
VIDEO,
|
VIDEO,
|
||||||
@@ -119,17 +122,15 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
|||||||
uris: List<Uri>,
|
uris: List<Uri>,
|
||||||
text: String?,
|
text: String?,
|
||||||
) {
|
) {
|
||||||
var allParams = ""
|
// 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) }
|
||||||
|
|
||||||
uris.forEachIndexed { index, uri ->
|
if (allParams.isEmpty()) return
|
||||||
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 encodedUris = URLEncoder.encode(allParams, "UTF-8")
|
||||||
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
|
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
|
||||||
@@ -158,12 +159,30 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
|||||||
}
|
}
|
||||||
val file = createFile(extension)
|
val file = createFile(extension)
|
||||||
|
|
||||||
val out = FileOutputStream(file)
|
// The URI may be unreadable (revoked permission, deleted file, or a
|
||||||
appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use {
|
// provider that rejects the read). Bail rather than crashing the whole
|
||||||
it.copyTo(out)
|
// 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 info = getVideoInfo(uri) ?: return
|
val info =
|
||||||
|
getVideoInfo(uri) ?: run {
|
||||||
|
file.delete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
|
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
|
||||||
|
|
||||||
@@ -176,15 +195,29 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getImageInfo(uri: Uri): Map<String, Any> {
|
private fun getImageInfo(uri: Uri): Map<String, Any>? {
|
||||||
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
|
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
|
||||||
// We have to save this so that we can access it later when uploading the image.
|
// 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"
|
// createTempFile will automatically place a unique string between "img" and "temp.jpeg"
|
||||||
val file = createFile("jpeg")
|
val file = createFile("jpeg")
|
||||||
val out = FileOutputStream(file)
|
try {
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
FileOutputStream(file).use { out ->
|
||||||
out.flush()
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||||
out.close()
|
out.flush()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to write shared image to cache", e)
|
||||||
|
file.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"width" to bitmap.width,
|
"width" to bitmap.width,
|
||||||
@@ -195,10 +228,19 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
|||||||
|
|
||||||
private fun getVideoInfo(uri: Uri): Map<String, Any>? {
|
private fun getVideoInfo(uri: Uri): Map<String, Any>? {
|
||||||
val retriever = MediaMetadataRetriever()
|
val retriever = MediaMetadataRetriever()
|
||||||
retriever.setDataSource(appContext.currentActivity, uri)
|
val width: Int?
|
||||||
|
val height: Int?
|
||||||
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
|
try {
|
||||||
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
if (width == null || height == null) {
|
if (width == null || height == null) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.123.0",
|
"version": "1.125.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=24.15.0"
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"devEngines": {
|
"devEngines": {
|
||||||
"packageManager": {
|
"packageManager": {
|
||||||
"name": "pnpm",
|
"name": "pnpm",
|
||||||
"version": "11.5.0",
|
"version": "11.7.0",
|
||||||
"onFail": "warn"
|
"onFail": "warn"
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"test-watch": "NODE_ENV=test jest --watchAll",
|
"test-watch": "NODE_ENV=test jest --watchAll",
|
||||||
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||||
"test-coverage": "NODE_ENV=test jest --coverage",
|
"test-coverage": "NODE_ENV=test jest --coverage",
|
||||||
"lint": "eslint --cache --quiet src",
|
"lint": "eslint --cache --quiet src modules",
|
||||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||||
"typecheck": "tsgo --project ./tsconfig.check.json",
|
"typecheck": "tsgo --project ./tsconfig.check.json",
|
||||||
@@ -93,19 +93,20 @@
|
|||||||
"prettier": "prettier --check ."
|
"prettier": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.20.8",
|
"@atproto/api": "0.20.12",
|
||||||
"@atproto/syntax": "0.6.1",
|
"@atproto/syntax": "0.6.1",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@bsky.app/alf": "^0.1.14",
|
"@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-guess-language": "^0.2.8",
|
||||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||||
"@bsky.app/peek-menu": "^0.2.4",
|
"@bsky.app/peek-menu": "^0.3.1",
|
||||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||||
"@bsky.app/sift": "^0.3.8",
|
"@bsky.app/sift": "^0.3.9",
|
||||||
"@bsky.app/tapper": "^0.5.7",
|
"@bsky.app/tapper": "^0.6.1",
|
||||||
"@bsky.app/video": "0.3.6",
|
"@bsky.app/video": "0.3.6",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
@@ -123,7 +124,6 @@
|
|||||||
"@ipld/dag-cbor": "^9.2.7",
|
"@ipld/dag-cbor": "^9.2.7",
|
||||||
"@lingui/core": "^5.9.2",
|
"@lingui/core": "^5.9.2",
|
||||||
"@lingui/react": "^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-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||||
"@react-navigation/native": "^7.1.33",
|
"@react-navigation/native": "^7.1.33",
|
||||||
|
|||||||
@@ -7,52 +7,52 @@ importers:
|
|||||||
configDependencies: {}
|
configDependencies: {}
|
||||||
packageManagerDependencies:
|
packageManagerDependencies:
|
||||||
'@pnpm/exe':
|
'@pnpm/exe':
|
||||||
specifier: 11.5.0
|
specifier: 11.7.0
|
||||||
version: 11.5.0
|
version: 11.7.0
|
||||||
pnpm:
|
pnpm:
|
||||||
specifier: 11.5.0
|
specifier: 11.7.0
|
||||||
version: 11.5.0
|
version: 11.7.0
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@pnpm/exe@11.5.0':
|
'@pnpm/exe@11.7.0':
|
||||||
resolution: {integrity: sha512-4hzOXq1HHrNPjwI8k1rt7Ot/Yrdx1JX3pn/L/M95ii1gid1Q6ZK6dVg4+gbSgUdPsYmYDZ4/Yfc0A7vd5C0ndg==}
|
resolution: {integrity: sha512-3CujpSSp2PIDE0pwu7mWSdjhdDqaZa7OppVooECWWaNEoA/z66s9FZts1MhDO+2yq1XER4gBHh84DVbFN/r1rA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@pnpm/linux-arm64@11.5.0':
|
'@pnpm/linux-arm64@11.7.0':
|
||||||
resolution: {integrity: sha512-NV9HdzzCB0epuI9LqZZeTaqjH3OweNQSQCS76GzEkFxJHS9e5Gvu7tgex91gxVL7bCZ+R4yr/3d3yexBFtr2ug==}
|
resolution: {integrity: sha512-ANTX2SlMO+d2y/4bYQhHCwHPX7gSSADJ5+pMUIiDFzIsybnFFaJdZboaFfq9NOxCbETcnDxqZ95Rz3+NHx1JIw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@pnpm/linux-x64@11.5.0':
|
'@pnpm/linux-x64@11.7.0':
|
||||||
resolution: {integrity: sha512-vH83rRx4iPk/bwm9pBVCn+5hXbcQI66I/4zk6Vc09SusJgTqOdbN4U6VhMcGIqSEdr901ksYGCyIbMv7f6Guew==}
|
resolution: {integrity: sha512-fr75tqixXoS8cnA81HQIomjOGEPnsOsd3xCDL5pMNY5raOXbKurtgRV+RjATvjxlJxSLIVFKegABlxAiB7q72A==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@pnpm/linuxstatic-arm64@11.5.0':
|
'@pnpm/linuxstatic-arm64@11.7.0':
|
||||||
resolution: {integrity: sha512-2nOnMW1rSwGv22q2yZz1HlGT3ly/Ij8wUlX0NB4n+Krx7nETRHA3MgWsbkVejxHknDcTulRVudAghuX9rgrXcw==}
|
resolution: {integrity: sha512-Q++pgzvXkGeqnVRl26/uqmpMGdttQus0rGyL3XIfYGLCi8ZfajYUaCKdZID2MH7+CNOuugWDdFDup3r7BR7Rfg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@pnpm/linuxstatic-x64@11.5.0':
|
'@pnpm/linuxstatic-x64@11.7.0':
|
||||||
resolution: {integrity: sha512-ONOC1Mg0JusHtjzkRlre9di1QO+GAjy4HP7jMjDx21yGhrSheNdUweTXbekMH1EflRd19kTU6d8M3zewJFPtVg==}
|
resolution: {integrity: sha512-z1+exW6ocU/rmOvJnmU3FUBJYaryCUqFoaXN6KZW5BqTj7BPJb7HJcAyXRlirNZMJlEiUY5rXbfStGPQJhGsVA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@pnpm/macos-arm64@11.5.0':
|
'@pnpm/macos-arm64@11.7.0':
|
||||||
resolution: {integrity: sha512-od0ALdTxs4A7s5vAH5q2l2phzCJb98+PVOW1rq7BGpWGeYxQ+EwvL+vq0KaO6iLsn/eVVoncCkgZ/k6QNYuTgw==}
|
resolution: {integrity: sha512-gD34/k3JT5oab4BYaqrUor3e4VdwXvkfLNlEI+lvDtX1MHT+2Nauc9p9NsQnpn1zE8blQEflzbF8wAUQ6Dmvkw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@pnpm/win-arm64@11.5.0':
|
'@pnpm/win-arm64@11.7.0':
|
||||||
resolution: {integrity: sha512-9HqbI80FjVVqFx4+EPxYYNfeP9Sx69W6kYqUDvOJn9G7RJ/2NNNQ898cVHTMpXlW1/PrMEcijmdpa/NjZIrWiQ==}
|
resolution: {integrity: sha512-hRTcDmm2j7KoRwbqNo0rUAu9A1kVJN98Eob7H09U0uPJbEMax85JGOmERkT3Lf6HjVJuFNBvfaJ3OTI3HmlVGg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@pnpm/win-x64@11.5.0':
|
'@pnpm/win-x64@11.7.0':
|
||||||
resolution: {integrity: sha512-Q89CQqFGAsWmfvHZs5Kbbar45q3GBYtfAdPUCiVMVNJoLi3dsBS2LCvUq8ak3AufkFDaJBpvhaFcDP2M1NXr3A==}
|
resolution: {integrity: sha512-r/1NuKY7Z+6ZXVIzVrUEMj6TTEBdR63geV4Rlm8HKEhgQTdxyIsJoqV3FJGqoyzbRaScObqAwRfMaK9dskPddQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@@ -116,45 +116,45 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
pnpm@11.5.0:
|
pnpm@11.7.0:
|
||||||
resolution: {integrity: sha512-2/zE+Bz0hZev1Lw5H/3xLBHxqfuDo5W/prCi2cwv2P/rr9scy9UpYyFT95OQTCYVt/Cf4aNFRz/Rw1hFFyqOsQ==}
|
resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==}
|
||||||
engines: {node: '>=22.13'}
|
engines: {node: '>=22.13'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@pnpm/exe@11.5.0':
|
'@pnpm/exe@11.7.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@reflink/reflink': 0.1.19
|
'@reflink/reflink': 0.1.19
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@pnpm/linux-arm64': 11.5.0
|
'@pnpm/linux-arm64': 11.7.0
|
||||||
'@pnpm/linux-x64': 11.5.0
|
'@pnpm/linux-x64': 11.7.0
|
||||||
'@pnpm/linuxstatic-arm64': 11.5.0
|
'@pnpm/linuxstatic-arm64': 11.7.0
|
||||||
'@pnpm/linuxstatic-x64': 11.5.0
|
'@pnpm/linuxstatic-x64': 11.7.0
|
||||||
'@pnpm/macos-arm64': 11.5.0
|
'@pnpm/macos-arm64': 11.7.0
|
||||||
'@pnpm/win-arm64': 11.5.0
|
'@pnpm/win-arm64': 11.7.0
|
||||||
'@pnpm/win-x64': 11.5.0
|
'@pnpm/win-x64': 11.7.0
|
||||||
|
|
||||||
'@pnpm/linux-arm64@11.5.0':
|
'@pnpm/linux-arm64@11.7.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linux-x64@11.5.0':
|
'@pnpm/linux-x64@11.7.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linuxstatic-arm64@11.5.0':
|
'@pnpm/linuxstatic-arm64@11.7.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/linuxstatic-x64@11.5.0':
|
'@pnpm/linuxstatic-x64@11.7.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/macos-arm64@11.5.0':
|
'@pnpm/macos-arm64@11.7.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/win-arm64@11.5.0':
|
'@pnpm/win-arm64@11.7.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pnpm/win-x64@11.5.0':
|
'@pnpm/win-x64@11.7.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||||
@@ -194,7 +194,7 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
pnpm@11.5.0: {}
|
pnpm@11.7.0: {}
|
||||||
|
|
||||||
---
|
---
|
||||||
lockfileVersion: '9.0'
|
lockfileVersion: '9.0'
|
||||||
@@ -242,8 +242,8 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/api':
|
'@atproto/api':
|
||||||
specifier: 0.20.8
|
specifier: 0.20.12
|
||||||
version: 0.20.8
|
version: 0.20.12
|
||||||
'@atproto/syntax':
|
'@atproto/syntax':
|
||||||
specifier: 0.6.1
|
specifier: 0.6.1
|
||||||
version: 0.6.1
|
version: 0.6.1
|
||||||
@@ -256,6 +256,9 @@ importers:
|
|||||||
'@bsky.app/alf':
|
'@bsky.app/alf':
|
||||||
specifier: ^0.1.14
|
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)
|
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':
|
'@bsky.app/expo-guess-language':
|
||||||
specifier: ^0.2.8
|
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)
|
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)
|
||||||
@@ -269,17 +272,17 @@ importers:
|
|||||||
specifier: ^0.2.9
|
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)
|
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':
|
'@bsky.app/peek-menu':
|
||||||
specifier: ^0.2.4
|
specifier: ^0.3.1
|
||||||
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)
|
version: 0.3.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)
|
||||||
'@bsky.app/react-native-mmkv':
|
'@bsky.app/react-native-mmkv':
|
||||||
specifier: 2.12.5
|
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)
|
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)
|
||||||
'@bsky.app/sift':
|
'@bsky.app/sift':
|
||||||
specifier: ^0.3.8
|
specifier: ^0.3.9
|
||||||
version: 0.3.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-safe-area-context@5.6.2(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)
|
version: 0.3.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-safe-area-context@5.6.2(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/tapper':
|
'@bsky.app/tapper':
|
||||||
specifier: ^0.5.7
|
specifier: ^0.6.1
|
||||||
version: 0.5.7(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)
|
version: 0.6.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)(tlds@1.261.0)
|
||||||
'@bsky.app/video':
|
'@bsky.app/video':
|
||||||
specifier: 0.3.6
|
specifier: 0.3.6
|
||||||
version: 0.3.6(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)
|
version: 0.3.6(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)
|
||||||
@@ -331,9 +334,6 @@ importers:
|
|||||||
'@lingui/react':
|
'@lingui/react':
|
||||||
specifier: ^5.9.2
|
specifier: ^5.9.2
|
||||||
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
|
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':
|
'@react-native-async-storage/async-storage':
|
||||||
specifier: 2.2.0
|
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))
|
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:
|
graphql:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@atproto/api@0.20.8':
|
'@atproto/api@0.20.12':
|
||||||
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==}
|
resolution: {integrity: sha512-pNCrl/BSmkjlrVu0W5A9zkOVRIWdWAdajuHX/EyhDZFxu8HExsiQi6j46H5kv85GrNRXS/QtULE+ocSuMtEJfw==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/common-web@0.5.0':
|
'@atproto/common-web@0.5.0':
|
||||||
@@ -1624,6 +1624,13 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
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':
|
'@bsky.app/expo-guess-language@0.2.8':
|
||||||
resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==}
|
resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1652,8 +1659,8 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
'@bsky.app/peek-menu@0.2.4':
|
'@bsky.app/peek-menu@0.3.1':
|
||||||
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
|
resolution: {integrity: sha512-ttFE7dIv1EJEmQn52WLvievzLNmPNNvPtoiz8ovl38jXLDMszL2wVPUYMnEjnZZqoq+K6HiBau9iHeCOKWwbTA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
react: '*'
|
react: '*'
|
||||||
@@ -1665,20 +1672,21 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '>=0.71.0'
|
react-native: '>=0.71.0'
|
||||||
|
|
||||||
'@bsky.app/sift@0.3.8':
|
'@bsky.app/sift@0.3.9':
|
||||||
resolution: {integrity: sha512-41Ug2QfjWJUApHsxcm6JDTb4r8jS6Qn6uBca8ofL10ZDKzXbSxDr1vK2yhFLyIeo/yIq3GU+XgEqrd1p6YgFiA==}
|
resolution: {integrity: sha512-EmwK6EGtxl6Mz4Z96J2EFi5aeiKeGiqueVCQHVms9C6ms6wMS0/HcqC/48RgFN+nSsM7Wr8sFCg1EcNnWyOQMQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
react-native-safe-area-context: ~5.6.0
|
react-native-safe-area-context: ~5.6.0
|
||||||
|
|
||||||
'@bsky.app/tapper@0.5.7':
|
'@bsky.app/tapper@0.6.1':
|
||||||
resolution: {integrity: sha512-DWU4v+llcEdgwueittiCSMeahrwvQAz4g1S7M1gSSw3Zor987fF7WO/NTDWq7X/zFab2nOQ/lnCvQUuOgFCTRg==}
|
resolution: {integrity: sha512-+suwTIQcZmMbFb/adzWAlAs7gzRsMmHAerzrpzZfMtt2cVrwPgOqI5IOeEEO8SmvEEImpE8IcHindG8hkCZrKA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
tlds: '*'
|
||||||
|
|
||||||
'@bsky.app/video@0.3.6':
|
'@bsky.app/video@0.3.6':
|
||||||
resolution: {integrity: sha512-Ny7vz9v1lQOw5Llw4xejtJo/Dp/lmdRw1XdUJucOXlq6puXO93tv3mNXUIIyOEVWEwDPg8j/kSQxG/mGy+/hdQ==}
|
resolution: {integrity: sha512-Ny7vz9v1lQOw5Llw4xejtJo/Dp/lmdRw1XdUJucOXlq6puXO93tv3mNXUIIyOEVWEwDPg8j/kSQxG/mGy+/hdQ==}
|
||||||
@@ -2327,13 +2335,6 @@ packages:
|
|||||||
'@messageformat/parser@5.1.1':
|
'@messageformat/parser@5.1.1':
|
||||||
resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==}
|
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':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
@@ -9493,7 +9494,7 @@ snapshots:
|
|||||||
|
|
||||||
'@0no-co/graphql.web@1.2.0': {}
|
'@0no-co/graphql.web@1.2.0': {}
|
||||||
|
|
||||||
'@atproto/api@0.20.8':
|
'@atproto/api@0.20.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/common-web': 0.5.0
|
'@atproto/common-web': 0.5.0
|
||||||
'@atproto/lexicon': 0.7.1
|
'@atproto/lexicon': 0.7.1
|
||||||
@@ -10446,6 +10447,14 @@ snapshots:
|
|||||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(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-responsive: 10.0.1(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)':
|
'@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:
|
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)
|
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)
|
||||||
@@ -10471,7 +10480,7 @@ snapshots:
|
|||||||
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-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.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/peek-menu@0.3.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:
|
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)
|
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: 19.1.0
|
||||||
@@ -10482,18 +10491,19 @@ snapshots:
|
|||||||
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-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||||
|
|
||||||
'@bsky.app/sift@0.3.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-safe-area-context@5.6.2(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/sift@0.3.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-safe-area-context@5.6.2(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:
|
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)
|
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: 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-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||||
react-native-safe-area-context: 5.6.2(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-safe-area-context: 5.6.2(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/tapper@0.5.7(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/tapper@0.6.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)(tlds@1.261.0)':
|
||||||
dependencies:
|
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)
|
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: 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-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||||
|
tlds: 1.261.0
|
||||||
|
|
||||||
'@bsky.app/video@0.3.6(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/video@0.3.6(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:
|
dependencies:
|
||||||
@@ -11449,14 +11459,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
moo: 0.5.3
|
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':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.10.0
|
'@emnapi/core': 1.10.0
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ import {
|
|||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {setNavigationMetadata} from '#/analytics/metadata'
|
import {setNavigationMetadata} from '#/analytics/metadata'
|
||||||
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||||
|
import {InviteScannerScreen} from '#/features/inviteFriends'
|
||||||
import {router} from '#/routes'
|
import {router} from '#/routes'
|
||||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||||
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
|
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
|
||||||
@@ -309,6 +310,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
|||||||
getComponent={() => DebugModScreen}
|
getComponent={() => DebugModScreen}
|
||||||
options={{title: title(msg`Moderation states`), requireAuth: true}}
|
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
|
<Stack.Screen
|
||||||
name="SharedPreferencesTester"
|
name="SharedPreferencesTester"
|
||||||
getComponent={() => SharedPreferencesTesterScreen}
|
getComponent={() => SharedPreferencesTesterScreen}
|
||||||
@@ -804,7 +810,7 @@ const LINKING = {
|
|||||||
return buildStateObject('Flat', 'Home', params)
|
return buildStateObject('Flat', 'Home', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
|
// Chat invite URLs (`/chat/:code`) are handled by `useIntentHandler`, which
|
||||||
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
|
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
|
||||||
// path to Home so the dialog overlays Home instead of NotFound. On native,
|
// path to Home so the dialog overlays Home instead of NotFound. On native,
|
||||||
// react-navigation strips the `bluesky://` prefix and passes the path
|
// react-navigation strips the `bluesky://` prefix and passes the path
|
||||||
@@ -844,6 +850,8 @@ const LINKING = {
|
|||||||
},
|
},
|
||||||
} satisfies LinkingOptions<AllNavigatorParams>
|
} satisfies LinkingOptions<AllNavigatorParams>
|
||||||
|
|
||||||
|
let didHandlePushNotificationEntry = false
|
||||||
|
|
||||||
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
// eslint-disable-next-line react-compiler/react-compiler
|
// eslint-disable-next-line react-compiler/react-compiler
|
||||||
@@ -904,6 +912,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
|||||||
function handlePushNotificationEntry() {
|
function handlePushNotificationEntry() {
|
||||||
if (!IS_NATIVE) return
|
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`
|
// intent urls are handled by `useIntentHandler`
|
||||||
if (linkingUrl) return
|
if (linkingUrl) return
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export const prefetchAgeAssuranceData = () => {}
|
export const prefetchAgeAssuranceServerData = () => {}
|
||||||
export const setBirthdateForDid = () => {}
|
export const setBirthdateForDid = () => {}
|
||||||
export const setCreatedAtForDid = () => {}
|
export const setCreatedAtForDid = () => {}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import * as Toast from '#/components/Toast'
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||||
import {useAgeAssurance} from '#/ageAssurance'
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||||
import {
|
import {
|
||||||
isLegacyBirthdateBug,
|
isLegacyBirthdateBug,
|
||||||
@@ -53,7 +53,7 @@ export function NoAccessScreen() {
|
|||||||
const birthdateControl = useDialogControl()
|
const birthdateControl = useDialogControl()
|
||||||
const deactivateAccountControl = useDialogControl()
|
const deactivateAccountControl = useDialogControl()
|
||||||
const deleteAccountControl = useDialogControl()
|
const deleteAccountControl = useDialogControl()
|
||||||
const {data} = useAgeAssuranceDataContext()
|
const {metadata} = useAgeAssuranceServerDataContext()
|
||||||
const region = useAgeAssuranceRegionConfig()
|
const region = useAgeAssuranceRegionConfig()
|
||||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||||
const {logoutCurrentAccount} = useSessionApi()
|
const {logoutCurrentAccount} = useSessionApi()
|
||||||
@@ -62,15 +62,15 @@ export function NoAccessScreen() {
|
|||||||
const aa = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
const isBlocked = aa.state.status === aa.Status.Blocked
|
const isBlocked = aa.state.status === aa.Status.Blocked
|
||||||
const isAARegion = !!region
|
const isAARegion = !!region
|
||||||
const hasDeclaredAge = data?.declaredAge !== undefined
|
const hasDeclaredAge = metadata?.declaredAge !== undefined
|
||||||
const canUpdateBirthday =
|
const canUpdateBirthday =
|
||||||
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
|
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// just counting overall hits here
|
// just counting overall hits here
|
||||||
ax.metric(`blockedGeoOverlay:shown`, {})
|
ax.metric(`blockedGeoOverlay:shown`, {})
|
||||||
ax.metric(`ageAssurance:noAccessScreen:shown`, {
|
ax.metric(`ageAssurance:noAccessScreen:shown`, {
|
||||||
accountCreatedAt: data?.accountCreatedAt || 'unknown',
|
accountCreatedAt: metadata?.accountCreatedAt || 'unknown',
|
||||||
isAARegion,
|
isAARegion,
|
||||||
hasDeclaredAge,
|
hasDeclaredAge,
|
||||||
canUpdateBirthday,
|
canUpdateBirthday,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declar
|
|||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import * as debug from '#/ageAssurance/debug'
|
import * as debug from '#/ageAssurance/debug'
|
||||||
import {logger} from '#/ageAssurance/logger'
|
import {logger} from '#/ageAssurance/logger'
|
||||||
|
import {type AgeAssuranceMetadata} from '#/ageAssurance/types'
|
||||||
import {
|
import {
|
||||||
getBirthdateStringFromAge,
|
getBirthdateStringFromAge,
|
||||||
isLegacyBirthdateBug,
|
isLegacyBirthdateBug,
|
||||||
@@ -485,9 +486,9 @@ export function useOtherRequiredDataQuery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to prefetch all age assurance data.
|
* Helper to prefetch all age assurance data from the server.
|
||||||
*/
|
*/
|
||||||
export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
|
export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
|
||||||
return Promise.allSettled([
|
return Promise.allSettled([
|
||||||
// config fetch initiated at the top of the App.platform.tsx files, awaited here
|
// config fetch initiated at the top of the App.platform.tsx files, awaited here
|
||||||
configPrefetchPromise,
|
configPrefetchPromise,
|
||||||
@@ -496,8 +497,8 @@ export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAgeAssuranceDataForDid({did}: {did: string}) {
|
export function clearAgeAssuranceServerDataForDid({did}: {did: string}) {
|
||||||
logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
|
logger.debug(`clearAgeAssuranceServerDataForDid: ${did}`)
|
||||||
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
|
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
|
||||||
qc.removeQueries({
|
qc.removeQueries({
|
||||||
queryKey: createOtherRequiredDataQueryKey({did}),
|
queryKey: createOtherRequiredDataQueryKey({did}),
|
||||||
@@ -505,8 +506,8 @@ export function clearAgeAssuranceDataForDid({did}: {did: string}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAgeAssuranceData() {
|
export function clearAgeAssuranceServerDataForAll() {
|
||||||
logger.debug(`clearAgeAssuranceData`)
|
logger.debug(`clearAgeAssuranceServerDataForAll`)
|
||||||
qc.clear()
|
qc.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,30 +515,30 @@ export function clearAgeAssuranceData() {
|
|||||||
* Context
|
* Context
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type AgeAssuranceData = {
|
export type AgeAssuranceServerData = {
|
||||||
|
/**
|
||||||
|
* The raw config from the appview.
|
||||||
|
*/
|
||||||
config: AppBskyAgeassuranceDefs.Config | undefined
|
config: AppBskyAgeassuranceDefs.Config | undefined
|
||||||
|
/**
|
||||||
|
* The raw state from the appview. Must be further processed before being useful.
|
||||||
|
*/
|
||||||
state: AppBskyAgeassuranceDefs.State | undefined
|
state: AppBskyAgeassuranceDefs.State | undefined
|
||||||
data:
|
metadata: AgeAssuranceMetadata | undefined
|
||||||
| {
|
|
||||||
accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
|
|
||||||
declaredAge: number | undefined
|
|
||||||
birthdate: string | undefined
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
}
|
}
|
||||||
export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
|
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||||
config: undefined,
|
config: undefined,
|
||||||
state: undefined,
|
state: undefined,
|
||||||
data: {
|
metadata: {
|
||||||
accountCreatedAt: undefined,
|
accountCreatedAt: undefined,
|
||||||
declaredAge: undefined,
|
declaredAge: undefined,
|
||||||
birthdate: undefined,
|
birthdate: undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export function useAgeAssuranceDataContext() {
|
export function useAgeAssuranceServerDataContext() {
|
||||||
return useContext(AgeAssuranceDataContext)
|
return useContext(AgeAssuranceServerDataContext)
|
||||||
}
|
}
|
||||||
export function AgeAssuranceDataProvider({
|
export function AgeAssuranceServerDataProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -550,7 +551,8 @@ export function AgeAssuranceDataProvider({
|
|||||||
() => ({
|
() => ({
|
||||||
config,
|
config,
|
||||||
state,
|
state,
|
||||||
data: {
|
metadata: {
|
||||||
|
// yes, it's weird, but accountCreatedAt comes back on the `getState` endpoint
|
||||||
accountCreatedAt: metadata?.accountCreatedAt,
|
accountCreatedAt: metadata?.accountCreatedAt,
|
||||||
declaredAge: data?.birthdate
|
declaredAge: data?.birthdate
|
||||||
? getAge(new Date(data.birthdate))
|
? getAge(new Date(data.birthdate))
|
||||||
@@ -561,8 +563,8 @@ export function AgeAssuranceDataProvider({
|
|||||||
[config, state, data, metadata],
|
[config, state, data, metadata],
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<AgeAssuranceDataContext.Provider value={ctx}>
|
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||||
{children}
|
{children}
|
||||||
</AgeAssuranceDataContext.Provider>
|
</AgeAssuranceServerDataContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,35 +26,8 @@ export const deviceGeolocation: Geolocation | undefined =
|
|||||||
}
|
}
|
||||||
: 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 = {
|
export const otherRequiredData: OtherRequiredData = {
|
||||||
birthdate: new Date(2000, 1, 1).toISOString(),
|
birthdate: new Date(2010, 12, 1).toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverStateEnabled = false || IS_E2E
|
const serverStateEnabled = false || IS_E2E
|
||||||
@@ -72,6 +45,218 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
|
|||||||
}
|
}
|
||||||
: 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) {
|
export async function resolve<T>(data: T) {
|
||||||
await new Promise(y => setTimeout(y, 500)) // simulate network
|
await new Promise(y => setTimeout(y, 500)) // simulate network
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
import {createContext, useCallback, useContext, useMemo} from 'react'
|
||||||
|
|
||||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||||
|
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
||||||
import {
|
import {
|
||||||
AgeAssuranceDataProvider,
|
AgeAssuranceServerDataProvider,
|
||||||
useAgeAssuranceDataContext,
|
useAgeAssuranceServerDataContext,
|
||||||
} from '#/ageAssurance/data'
|
} from '#/ageAssurance/data'
|
||||||
import {logger} from '#/ageAssurance/logger'
|
import {logger} from '#/ageAssurance/logger'
|
||||||
import {
|
import {
|
||||||
@@ -14,37 +15,29 @@ import {
|
|||||||
} from '#/ageAssurance/state'
|
} from '#/ageAssurance/state'
|
||||||
import {
|
import {
|
||||||
AgeAssuranceAccess,
|
AgeAssuranceAccess,
|
||||||
|
type AgeAssuranceFlags,
|
||||||
type AgeAssuranceState,
|
type AgeAssuranceState,
|
||||||
AgeAssuranceStatus,
|
AgeAssuranceStatus,
|
||||||
} from '#/ageAssurance/types'
|
} from '#/ageAssurance/types'
|
||||||
import {
|
import {
|
||||||
isUnderAge,
|
computeAgeAssuranceFlags,
|
||||||
maybeRestrictChatSettings,
|
|
||||||
MIN_ACCESS_AGE,
|
|
||||||
useAgeAssuranceRegionConfigWithFallback,
|
useAgeAssuranceRegionConfigWithFallback,
|
||||||
} from '#/ageAssurance/util'
|
} from '#/ageAssurance/util'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
prefetchConfig as prefetchAgeAssuranceConfig,
|
prefetchConfig as prefetchAgeAssuranceConfig,
|
||||||
prefetchAgeAssuranceData,
|
prefetchAgeAssuranceServerData,
|
||||||
refetchServerState as refetchAgeAssuranceServerState,
|
refetchServerState as refetchAgeAssuranceServerState,
|
||||||
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
|
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
|
||||||
usePatchServerState as usePatchAgeAssuranceServerState,
|
usePatchServerState as usePatchAgeAssuranceServerState,
|
||||||
} from '#/ageAssurance/data'
|
} from '#/ageAssurance/data'
|
||||||
export {logger} from '#/ageAssurance/logger'
|
export {logger} from '#/ageAssurance/logger'
|
||||||
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
|
|
||||||
|
|
||||||
const AgeAssuranceStateContext = createContext<{
|
const AgeAssuranceStateContext = createContext<{
|
||||||
Access: typeof AgeAssuranceAccess
|
Access: typeof AgeAssuranceAccess
|
||||||
Status: typeof AgeAssuranceStatus
|
Status: typeof AgeAssuranceStatus
|
||||||
state: AgeAssuranceState
|
state: AgeAssuranceState
|
||||||
flags: {
|
flags: AgeAssuranceFlags
|
||||||
adultContentDisabled: boolean
|
|
||||||
chatDisabled: boolean
|
|
||||||
isDeclaredUnderAdultAge: boolean
|
|
||||||
isOverRegionMinAccessAge: boolean
|
|
||||||
isOverAppMinAccessAge: boolean
|
|
||||||
}
|
|
||||||
}>({
|
}>({
|
||||||
Access: AgeAssuranceAccess,
|
Access: AgeAssuranceAccess,
|
||||||
Status: AgeAssuranceStatus,
|
Status: AgeAssuranceStatus,
|
||||||
@@ -54,8 +47,10 @@ const AgeAssuranceStateContext = createContext<{
|
|||||||
access: AgeAssuranceAccess.Full,
|
access: AgeAssuranceAccess.Full,
|
||||||
},
|
},
|
||||||
flags: {
|
flags: {
|
||||||
|
isAgeRestricted: false,
|
||||||
adultContentDisabled: false,
|
adultContentDisabled: false,
|
||||||
chatDisabled: false,
|
chatDisabled: false,
|
||||||
|
groupChatDisabled: false,
|
||||||
isDeclaredUnderAdultAge: false,
|
isDeclaredUnderAdultAge: false,
|
||||||
isOverRegionMinAccessAge: false,
|
isOverRegionMinAccessAge: false,
|
||||||
isOverAppMinAccessAge: false,
|
isOverAppMinAccessAge: false,
|
||||||
@@ -73,65 +68,61 @@ export function useAgeAssurance() {
|
|||||||
|
|
||||||
export function Provider({children}: {children: React.ReactNode}) {
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
return (
|
return (
|
||||||
<AgeAssuranceDataProvider>
|
<AgeAssuranceServerDataProvider>
|
||||||
<InnerProvider>
|
<InnerProvider>
|
||||||
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
|
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
|
||||||
</InnerProvider>
|
</InnerProvider>
|
||||||
</AgeAssuranceDataProvider>
|
</AgeAssuranceServerDataProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InnerProvider({children}: {children: React.ReactNode}) {
|
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const state = useAgeAssuranceState()
|
const state = useAgeAssuranceState()
|
||||||
const {data} = useAgeAssuranceDataContext()
|
const {metadata} = useAgeAssuranceServerDataContext()
|
||||||
const config = useAgeAssuranceRegionConfigWithFallback()
|
const regionConfig = useAgeAssuranceRegionConfigWithFallback()
|
||||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||||
|
|
||||||
const handleAccessUpdate = useCallback(
|
const handleAccessUpdate = useCallback(
|
||||||
(s: AgeAssuranceState) => {
|
(s: AgeAssuranceState) => {
|
||||||
const isAgeRestricted = s.access !== AgeAssuranceAccess.Full
|
const flags = computeAgeAssuranceFlags({
|
||||||
if (isAgeRestricted) {
|
state: s,
|
||||||
void getAndRegisterPushToken({isAgeRestricted})
|
regionConfig,
|
||||||
maybeRestrictChatSettings({agent})
|
metadata,
|
||||||
|
})
|
||||||
|
if (flags.isAgeRestricted) {
|
||||||
|
void getAndRegisterPushToken({
|
||||||
|
isAgeRestricted: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (flags.chatDisabled || flags.groupChatDisabled) {
|
||||||
|
void restrictChatSettings({
|
||||||
|
agent,
|
||||||
|
restrictIncoming: flags.chatDisabled,
|
||||||
|
restrictGroupInvites: flags.groupChatDisabled,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agent, getAndRegisterPushToken],
|
[agent, getAndRegisterPushToken, regionConfig, metadata],
|
||||||
)
|
)
|
||||||
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
logger.debug(`useAgeAssuranceState`, {state})
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgeAssuranceStateContext.Provider
|
<AgeAssuranceStateContext.Provider
|
||||||
value={useMemo(() => {
|
value={useMemo(() => {
|
||||||
const chatDisabled = state.access !== AgeAssuranceAccess.Full
|
const res = {
|
||||||
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,
|
Access: AgeAssuranceAccess,
|
||||||
Status: AgeAssuranceStatus,
|
Status: AgeAssuranceStatus,
|
||||||
state,
|
state,
|
||||||
flags: {
|
flags: computeAgeAssuranceFlags({
|
||||||
adultContentDisabled,
|
state,
|
||||||
chatDisabled,
|
regionConfig,
|
||||||
isDeclaredUnderAdultAge,
|
metadata,
|
||||||
isOverRegionMinAccessAge,
|
}),
|
||||||
isOverAppMinAccessAge,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}, [state, data, config])}>
|
logger.debug(`useAgeAssurance`, res)
|
||||||
|
return res
|
||||||
|
}, [state, metadata, regionConfig])}>
|
||||||
{children}
|
{children}
|
||||||
</AgeAssuranceStateContext.Provider>
|
</AgeAssuranceStateContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react'
|
import {useEffect, useMemo, useState} from 'react'
|
||||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
import {
|
||||||
|
type AppBskyAgeassuranceDefs,
|
||||||
|
computeAgeAssuranceRegionAccess,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {
|
import {
|
||||||
type AgeAssuranceData,
|
|
||||||
getConfigFromCache,
|
getConfigFromCache,
|
||||||
getOtherRequiredDataFromCache,
|
getOtherRequiredDataFromCache,
|
||||||
getServerStateFromCache,
|
getServerStateFromCache,
|
||||||
useAgeAssuranceDataContext,
|
useAgeAssuranceServerDataContext,
|
||||||
} from '#/ageAssurance/data'
|
} from '#/ageAssurance/data'
|
||||||
import {logger} from '#/ageAssurance/logger'
|
import {logger} from '#/ageAssurance/logger'
|
||||||
import {
|
import {
|
||||||
AgeAssuranceAccess,
|
AgeAssuranceAccess,
|
||||||
|
type AgeAssuranceMetadata,
|
||||||
type AgeAssuranceState,
|
type AgeAssuranceState,
|
||||||
AgeAssuranceStatus,
|
AgeAssuranceStatus,
|
||||||
parseAccessFromString,
|
parseAccessFromString,
|
||||||
parseStatusFromString,
|
parseStatusFromString,
|
||||||
} from '#/ageAssurance/types'
|
} from '#/ageAssurance/types'
|
||||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
import {
|
||||||
|
computeAgeAssuranceFlags,
|
||||||
|
getAgeAssuranceRegionConfigWithFallback,
|
||||||
|
} from '#/ageAssurance/util'
|
||||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||||
import {device} from '#/storage'
|
import {device} from '#/storage'
|
||||||
|
|
||||||
@@ -27,18 +33,18 @@ import {device} from '#/storage'
|
|||||||
* server state before computing access based on AA config from the server +
|
* server state before computing access based on AA config from the server +
|
||||||
* geolocation and other data.
|
* geolocation and other data.
|
||||||
*/
|
*/
|
||||||
export function computeAgeAssuranceState({
|
function computeAgeAssuranceState({
|
||||||
hasSession,
|
hasSession,
|
||||||
config,
|
|
||||||
geolocation,
|
geolocation,
|
||||||
|
config,
|
||||||
state,
|
state,
|
||||||
data,
|
metadata,
|
||||||
}: {
|
}: {
|
||||||
hasSession: boolean
|
hasSession: boolean
|
||||||
config: AgeAssuranceData['config']
|
|
||||||
geolocation: Geolocation
|
geolocation: Geolocation
|
||||||
state: AgeAssuranceData['state']
|
config?: AppBskyAgeassuranceDefs.Config
|
||||||
data: AgeAssuranceData['data']
|
state?: AppBskyAgeassuranceDefs.State
|
||||||
|
metadata?: AgeAssuranceMetadata
|
||||||
}) {
|
}) {
|
||||||
/**
|
/**
|
||||||
* This is where we control logged-out moderation prefs. It's all
|
* This is where we control logged-out moderation prefs. It's all
|
||||||
@@ -88,7 +94,10 @@ export function computeAgeAssuranceState({
|
|||||||
* accounts with an accurate birthdate, our default fallback rules should
|
* accounts with an accurate birthdate, our default fallback rules should
|
||||||
* ensure correct access.
|
* ensure correct access.
|
||||||
*/
|
*/
|
||||||
const result = computeAgeAssuranceRegionAccess(region, data)
|
const result = computeAgeAssuranceRegionAccess(region, {
|
||||||
|
accountCreatedAt: metadata?.accountCreatedAt,
|
||||||
|
declaredAge: metadata?.declaredAge,
|
||||||
|
})
|
||||||
const computed = {
|
const computed = {
|
||||||
lastInitiatedAt: state?.lastInitiatedAt,
|
lastInitiatedAt: state?.lastInitiatedAt,
|
||||||
// prefer server state
|
// prefer server state
|
||||||
@@ -100,10 +109,10 @@ export function computeAgeAssuranceState({
|
|||||||
? parseAccessFromString(result.access)
|
? parseAccessFromString(result.access)
|
||||||
: AgeAssuranceAccess.Full,
|
: AgeAssuranceAccess.Full,
|
||||||
}
|
}
|
||||||
logger.debug('debug useAgeAssuranceState', {
|
logger.debug('computeAgeAssuranceState', {
|
||||||
region,
|
region,
|
||||||
state,
|
state,
|
||||||
data,
|
metadata,
|
||||||
computed,
|
computed,
|
||||||
})
|
})
|
||||||
return computed
|
return computed
|
||||||
@@ -113,38 +122,51 @@ export function computeAgeAssuranceState({
|
|||||||
* This is a last-ditch helper for out-of-band reads of the AA state, such as
|
* 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.
|
* during account creation. Don't use it for anything else.
|
||||||
*/
|
*/
|
||||||
export function getAndComputeAgeAssuranceState({did}: {did: string}) {
|
export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||||
const config = getConfigFromCache()
|
const config = getConfigFromCache()
|
||||||
const state = getServerStateFromCache({did})
|
const state = getServerStateFromCache({did})
|
||||||
const data = getOtherRequiredDataFromCache({did})
|
const requiredData = getOtherRequiredDataFromCache({did})
|
||||||
const geolocation = device.get(['mergedGeolocation'])
|
const geolocation = device.get(['mergedGeolocation'])
|
||||||
|
|
||||||
if (!geolocation || !config || !state || !data) {
|
if (!geolocation || !config || !state || !requiredData) {
|
||||||
return {
|
return {
|
||||||
status: AgeAssuranceStatus.Unknown,
|
state: {
|
||||||
access: AgeAssuranceAccess.Safe,
|
status: AgeAssuranceStatus.Unknown,
|
||||||
|
access: AgeAssuranceAccess.Safe,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return computeAgeAssuranceState({
|
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({
|
||||||
hasSession: true,
|
hasSession: true,
|
||||||
config,
|
config,
|
||||||
geolocation,
|
geolocation,
|
||||||
state: state.state,
|
state: state.state,
|
||||||
data: {
|
metadata,
|
||||||
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 {
|
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
const geolocation = useGeolocation()
|
const geolocation = useGeolocation()
|
||||||
const {config, state, data} = useAgeAssuranceDataContext()
|
const {config, state, metadata} = useAgeAssuranceServerDataContext()
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -153,9 +175,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
|||||||
config,
|
config,
|
||||||
geolocation,
|
geolocation,
|
||||||
state,
|
state,
|
||||||
data,
|
metadata,
|
||||||
}),
|
}),
|
||||||
[hasSession, geolocation, config, state, data],
|
[hasSession, geolocation, config, state, metadata],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||||
|
|
||||||
import {logger} from '#/ageAssurance/logger'
|
import {logger} from '#/ageAssurance/logger'
|
||||||
|
|
||||||
export enum AgeAssuranceAccess {
|
export enum AgeAssuranceAccess {
|
||||||
@@ -14,6 +16,12 @@ export enum AgeAssuranceStatus {
|
|||||||
Blocked = 'blocked',
|
Blocked = 'blocked',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AgeAssuranceMetadata = Parameters<
|
||||||
|
typeof computeAgeAssuranceRegionAccess
|
||||||
|
>[1] & {
|
||||||
|
birthdate: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export type AgeAssuranceState = {
|
export type AgeAssuranceState = {
|
||||||
lastInitiatedAt?: string
|
lastInitiatedAt?: string
|
||||||
status: AgeAssuranceStatus
|
status: AgeAssuranceStatus
|
||||||
@@ -21,6 +29,16 @@ export type AgeAssuranceState = {
|
|||||||
error?: 'config' // maybe other specific cases in the future
|
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) {
|
export function parseStatusFromString(raw: string) {
|
||||||
switch (raw) {
|
switch (raw) {
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import {useCallback} from 'react'
|
import {useCallback} from 'react'
|
||||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||||
|
|
||||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||||
import {logger} from '#/ageAssurance/logger'
|
import {logger} from '#/ageAssurance/logger'
|
||||||
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
|
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
|
||||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||||
import {type Geolocation} from '#/geolocation'
|
import {type Geolocation} from '#/geolocation'
|
||||||
|
|
||||||
export function useComputeAgeAssuranceRegionAccess() {
|
export function useComputeAgeAssuranceRegionAccess() {
|
||||||
const {config, data} = useAgeAssuranceDataContext()
|
const {config, metadata} = useAgeAssuranceServerDataContext()
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(geolocation: Geolocation) => {
|
(geolocation: Geolocation) => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -19,11 +19,14 @@ export function useComputeAgeAssuranceRegionAccess() {
|
|||||||
config,
|
config,
|
||||||
geolocation,
|
geolocation,
|
||||||
)
|
)
|
||||||
const result = computeAgeAssuranceRegionAccess(region, data)
|
const result = computeAgeAssuranceRegionAccess(region, {
|
||||||
|
accountCreatedAt: metadata?.accountCreatedAt,
|
||||||
|
declaredAge: metadata?.declaredAge,
|
||||||
|
})
|
||||||
return result
|
return result
|
||||||
? parseAccessFromString(result.access)
|
? parseAccessFromString(result.access)
|
||||||
: AgeAssuranceAccess.Full
|
: AgeAssuranceAccess.Full
|
||||||
},
|
},
|
||||||
[config, data],
|
[config, metadata],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,22 @@
|
|||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {
|
import {
|
||||||
ageAssuranceRuleIDs as ids,
|
|
||||||
type AppBskyAgeassuranceDefs,
|
type AppBskyAgeassuranceDefs,
|
||||||
type AtpAgent,
|
|
||||||
getAgeAssuranceRegionConfig,
|
getAgeAssuranceRegionConfig,
|
||||||
type ModerationPrefs,
|
type ModerationPrefs,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
|
||||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
|
||||||
|
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||||
import {
|
import {
|
||||||
getDidFromAgentSession,
|
AgeAssuranceAccess,
|
||||||
getOtherRequiredDataFromCache,
|
type AgeAssuranceFlags,
|
||||||
useAgeAssuranceDataContext,
|
type AgeAssuranceMetadata,
|
||||||
} from '#/ageAssurance/data'
|
type AgeAssuranceState,
|
||||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
} from '#/ageAssurance/types'
|
||||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
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
|
* Get age assurance region config based on geolocation, with fallback to
|
||||||
* app defaults if no region config is found.
|
* app defaults if no region config is found.
|
||||||
@@ -62,7 +43,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
|
|||||||
*/
|
*/
|
||||||
export function useAgeAssuranceRegionConfig() {
|
export function useAgeAssuranceRegionConfig() {
|
||||||
const geolocation = useGeolocation()
|
const geolocation = useGeolocation()
|
||||||
const {config} = useAgeAssuranceDataContext()
|
const {config} = useAgeAssuranceServerDataContext()
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
// use generic helper, we want to potentially return undefined
|
// use generic helper, we want to potentially return undefined
|
||||||
@@ -116,15 +97,37 @@ export const makeAgeRestrictedModerationPrefs = (
|
|||||||
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
export function computeAgeAssuranceFlags({
|
||||||
* Checks our cache of the actor's chat declaration record, and if it's not
|
state,
|
||||||
* already restricted, restricts it.
|
regionConfig,
|
||||||
*/
|
metadata,
|
||||||
export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) {
|
}: {
|
||||||
const did = getDidFromAgentSession(agent)
|
state: AgeAssuranceState
|
||||||
if (!did) return
|
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
|
||||||
const data = getOtherRequiredDataFromCache({did})
|
metadata?: AgeAssuranceMetadata
|
||||||
// ...update the chat setting record if allowIncoming is not already 'none'.
|
}): AgeAssuranceFlags {
|
||||||
if (data?.actorDeclaration?.allowIncoming === 'none') return
|
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
|
||||||
restrictChatSettings({agent, did})
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
setFontScale as persistFontScale,
|
setFontScale as persistFontScale,
|
||||||
} from '#/alf/fonts'
|
} from '#/alf/fonts'
|
||||||
import {themes} from '#/alf/themes'
|
import {themes} from '#/alf/themes'
|
||||||
import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration'
|
import {
|
||||||
|
contrastRatio,
|
||||||
|
darken,
|
||||||
|
lighten,
|
||||||
|
rgbToHex,
|
||||||
|
} from '#/alf/util/colorGeneration'
|
||||||
import {type Device} from '#/storage'
|
import {type Device} from '#/storage'
|
||||||
|
|
||||||
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
|
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
|
||||||
@@ -26,6 +31,7 @@ export const utils = {
|
|||||||
rgbToHex,
|
rgbToHex,
|
||||||
lighten,
|
lighten,
|
||||||
darken,
|
darken,
|
||||||
|
contrastRatio,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Alf = {
|
export type Alf = {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration'
|
import {
|
||||||
|
contrastRatio,
|
||||||
|
darken,
|
||||||
|
hexToRgb,
|
||||||
|
lighten,
|
||||||
|
rgbToHex,
|
||||||
|
} from './colorGeneration'
|
||||||
|
|
||||||
describe('hexToRgb', () => {
|
describe('hexToRgb', () => {
|
||||||
it('parses 6-digit hex', () => {
|
it('parses 6-digit hex', () => {
|
||||||
@@ -92,3 +98,33 @@ describe('lighten / darken', () => {
|
|||||||
expect(darken('#zzz', 10)).toBe('#zzz')
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -72,6 +72,48 @@ export function rgbToHex(r: number, g: number, b: number): string {
|
|||||||
.slice(1)}`
|
.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(
|
function rgbToHsl(
|
||||||
r: number,
|
r: number,
|
||||||
g: number,
|
g: number,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@ import {useEffect, useRef} from 'react'
|
|||||||
|
|
||||||
import {getCurrentState, onAppStateChange} from '#/lib/appState'
|
import {getCurrentState, onAppStateChange} from '#/lib/appState'
|
||||||
import {useAnalytics} from '#/analytics'
|
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.
|
* Tracks passive analytics like app foreground/background time.
|
||||||
@@ -27,19 +25,19 @@ export function PassiveAnalytics() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IS_DEV || IS_TESTFLIGHT) {
|
// if (IS_DEV || IS_TESTFLIGHT) {
|
||||||
const feats = Object.values(Features).reduce(
|
// const feats = Object.values(Features).reduce(
|
||||||
(acc, feat) => {
|
// (acc, feat) => {
|
||||||
acc[feat] = features.evalFeature(feat)
|
// acc[feat] = features.evalFeature(feat)
|
||||||
return acc
|
// return acc
|
||||||
},
|
// },
|
||||||
{} as Record<Features, any>,
|
// {} as Record<Features, any>,
|
||||||
)
|
// )
|
||||||
ax.logger.info('FEATURES', {
|
// ax.logger.info('FEATURES', {
|
||||||
features: feats,
|
// features: feats,
|
||||||
definitions: features.getFeatures(),
|
// definitions: features.getFeatures(),
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
})
|
})
|
||||||
return () => sub.remove()
|
return () => sub.remove()
|
||||||
}, [ax])
|
}, [ax])
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export enum Features {
|
|||||||
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
||||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||||
GroupChatsEnable = 'group_chats:enable',
|
GroupChatsDisable = 'group_chats:disable',
|
||||||
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
|
||||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||||
|
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
||||||
|
|
||||||
AATest = 'aa-test',
|
AATest = 'aa-test',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ export class MetricsClient<M extends Record<string, any>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
|
private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
|
||||||
logger.debug(`sendBatch: ${events.length}`, {
|
|
||||||
isRetry,
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = JSON.stringify({events})
|
const body = JSON.stringify({events})
|
||||||
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
|
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
|
||||||
|
|||||||
@@ -586,9 +586,78 @@ export type Events = {
|
|||||||
| 'SendViaChatDialog'
|
| 'SendViaChatDialog'
|
||||||
| 'ConvoSettings'
|
| 'ConvoSettings'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group chat adoption
|
||||||
'groupchat:create': {
|
'groupchat:create': {
|
||||||
logContext: 'NewChatDialog'
|
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:addUser': {
|
||||||
starterPack?: string
|
starterPack?: string
|
||||||
}
|
}
|
||||||
@@ -1175,18 +1244,76 @@ export type Events = {
|
|||||||
'profile:associated:germ:self-disconnect': {}
|
'profile:associated:germ:self-disconnect': {}
|
||||||
'profile:associated:germ:self-reconnect': {}
|
'profile:associated:germ:self-reconnect': {}
|
||||||
|
|
||||||
// Gallery carousel events
|
// Post photo embed events
|
||||||
'post:gallery:swipe': {
|
'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': {
|
||||||
fromImage: number
|
fromImage: number
|
||||||
toImage: number
|
toImage: number
|
||||||
totalImages: number
|
totalImages: number
|
||||||
|
postUri: string
|
||||||
|
postAuthorDid: string
|
||||||
|
feedDescriptor?: string
|
||||||
}
|
}
|
||||||
'post:gallery:openLightbox': {
|
'post:photoEmbed:lightboxSwipe': {
|
||||||
|
layout: 'single' | 'grid' | 'carousel'
|
||||||
fromImage: number
|
fromImage: number
|
||||||
totalImages: number
|
toImage: number
|
||||||
}
|
|
||||||
'post:gallery:impression': {
|
|
||||||
totalImages: number
|
totalImages: number
|
||||||
postUri: string
|
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': {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useEffect, useMemo} from 'react'
|
import {useEffect} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -8,12 +8,10 @@ import Animated, {
|
|||||||
withDelay,
|
withDelay,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {
|
import {moderateProfile} from '@atproto/api'
|
||||||
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 {useSession} from '#/state/session'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
@@ -33,7 +31,7 @@ export function AvatarBubbles({
|
|||||||
profiles: allProfiles,
|
profiles: allProfiles,
|
||||||
self = false,
|
self = false,
|
||||||
size = 120,
|
size = 120,
|
||||||
moderationOpts,
|
count,
|
||||||
}: {
|
}: {
|
||||||
animate?: boolean
|
animate?: boolean
|
||||||
profiles: bsky.profile.AnyProfileView[]
|
profiles: bsky.profile.AnyProfileView[]
|
||||||
@@ -45,19 +43,21 @@ export function AvatarBubbles({
|
|||||||
*/
|
*/
|
||||||
self?: boolean
|
self?: boolean
|
||||||
size?: number
|
size?: number
|
||||||
moderationOpts?: ModerationOpts
|
/**
|
||||||
|
* The true number of members, used to decide how many bubbles to render when
|
||||||
|
* it exceeds the number of `profiles` we have on hand (e.g. an invite preview
|
||||||
|
* that only carries a few member profiles for a much larger group). Slots
|
||||||
|
* without a profile render as placeholders. Defaults to `profiles.length`.
|
||||||
|
*/
|
||||||
|
count?: number
|
||||||
}) {
|
}) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const profiles =
|
const profiles =
|
||||||
!self && allProfiles.length > 2
|
!self && allProfiles.length > 2
|
||||||
? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
|
? allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||||
: allProfiles
|
: allProfiles
|
||||||
const moderations = useMemo(() => {
|
|
||||||
if (!moderationOpts) return []
|
const bubbleCount = Math.max(profiles.length, count ?? 0)
|
||||||
return profiles.map(p => {
|
|
||||||
return moderateProfile(p, moderationOpts)
|
|
||||||
})
|
|
||||||
}, [profiles, moderationOpts])
|
|
||||||
|
|
||||||
const scale = size / 120
|
const scale = size / 120
|
||||||
const marginOffset = size < 120 ? -2 : 0
|
const marginOffset = size < 120 ? -2 : 0
|
||||||
@@ -89,7 +89,7 @@ export function AvatarBubbles({
|
|||||||
}, [animate, p0, p1, p2, p3])
|
}, [animate, p0, p1, p2, p3])
|
||||||
|
|
||||||
const scales = [p0, p1, p2, p3]
|
const scales = [p0, p1, p2, p3]
|
||||||
const layouts = getLayouts(profiles.length)
|
const layouts = getLayouts(bubbleCount)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
|
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
|
||||||
@@ -110,7 +110,6 @@ export function AvatarBubbles({
|
|||||||
y={layout.y}
|
y={layout.y}
|
||||||
zIndex={layout.zIndex}
|
zIndex={layout.zIndex}
|
||||||
includeProfileBorder={layout.border}
|
includeProfileBorder={layout.border}
|
||||||
moderation={moderations[i]?.ui('avatar')}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -119,14 +118,13 @@ export function AvatarBubbles({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AvatarBubble({
|
function AvatarBubble({
|
||||||
profile,
|
profile: profileUnshadowed,
|
||||||
scale,
|
scale,
|
||||||
size,
|
size,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
zIndex,
|
zIndex,
|
||||||
includeProfileBorder,
|
includeProfileBorder,
|
||||||
moderation,
|
|
||||||
}: {
|
}: {
|
||||||
profile?: bsky.profile.AnyProfileView
|
profile?: bsky.profile.AnyProfileView
|
||||||
scale: SharedValue<number>
|
scale: SharedValue<number>
|
||||||
@@ -135,14 +133,15 @@ function AvatarBubble({
|
|||||||
y: number
|
y: number
|
||||||
zIndex?: number
|
zIndex?: number
|
||||||
includeProfileBorder?: boolean
|
includeProfileBorder?: boolean
|
||||||
moderation?: ModerationUI
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
|
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const profile = useMaybeProfileShadow(profileUnshadowed)
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
@@ -156,14 +155,14 @@ function AvatarBubble({
|
|||||||
zIndex != null && {zIndex},
|
zIndex != null && {zIndex},
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
]}>
|
]}>
|
||||||
{profile ? (
|
{profile && moderationOpts ? (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
size={size}
|
size={size}
|
||||||
type="user"
|
type="user"
|
||||||
hideLiveBadge
|
hideLiveBadge
|
||||||
noBorder
|
noBorder
|
||||||
moderation={moderation}
|
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AvatarPlaceholder size={size} />
|
<AvatarPlaceholder size={size} />
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export type ButtonColor =
|
|||||||
| 'negative'
|
| 'negative'
|
||||||
| 'primary_subtle'
|
| 'primary_subtle'
|
||||||
| 'negative_subtle'
|
| 'negative_subtle'
|
||||||
export type ButtonSize = 'tiny' | 'small' | 'large'
|
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
|
||||||
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
|
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
|
||||||
export type VariantProps = {
|
export type VariantProps = {
|
||||||
/**
|
/**
|
||||||
@@ -136,7 +136,7 @@ export const Button = forwardRef<View, ButtonProps>(
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
children,
|
children,
|
||||||
variant,
|
variant: variantProp,
|
||||||
color,
|
color,
|
||||||
size,
|
size,
|
||||||
shape = 'default',
|
shape = 'default',
|
||||||
@@ -160,7 +160,8 @@ export const Button = forwardRef<View, ButtonProps>(
|
|||||||
* If a `color` is set, then we want to use the existing codepaths for
|
* If a `color` is set, then we want to use the existing codepaths for
|
||||||
* "solid" buttons. This is to maintain backwards compatibility.
|
* "solid" buttons. This is to maintain backwards compatibility.
|
||||||
*/
|
*/
|
||||||
if (!variant && color) {
|
let variant: VariantProps['variant'] = variantProp
|
||||||
|
if (!variantProp && color) {
|
||||||
variant = 'solid'
|
variant = 'solid'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,6 +459,12 @@ export const Button = forwardRef<View, ButtonProps>(
|
|||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
gap: 6,
|
gap: 6,
|
||||||
})
|
})
|
||||||
|
} else if (size === 'medium') {
|
||||||
|
baseStyles.push(a.rounded_full, {
|
||||||
|
paddingVertical: 9,
|
||||||
|
paddingHorizontal: 28,
|
||||||
|
gap: 5,
|
||||||
|
})
|
||||||
} else if (size === 'small') {
|
} else if (size === 'small') {
|
||||||
baseStyles.push(a.rounded_full, {
|
baseStyles.push(a.rounded_full, {
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
@@ -479,6 +486,13 @@ export const Button = forwardRef<View, ButtonProps>(
|
|||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
gap: 3,
|
gap: 3,
|
||||||
})
|
})
|
||||||
|
} else if (size === 'medium') {
|
||||||
|
baseStyles.push({
|
||||||
|
paddingVertical: 9,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
gap: 3,
|
||||||
|
})
|
||||||
} else if (size === 'small') {
|
} else if (size === 'small') {
|
||||||
baseStyles.push({
|
baseStyles.push({
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
@@ -505,6 +519,12 @@ export const Button = forwardRef<View, ButtonProps>(
|
|||||||
} else {
|
} else {
|
||||||
baseStyles.push({height: 44, width: 44})
|
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') {
|
} else if (size === 'small') {
|
||||||
if (shape === 'round') {
|
if (shape === 'round') {
|
||||||
baseStyles.push({height: 33, width: 33})
|
baseStyles.push({height: 33, width: 33})
|
||||||
@@ -758,6 +778,8 @@ export function useSharedButtonTextStyles() {
|
|||||||
|
|
||||||
if (size === 'large') {
|
if (size === 'large') {
|
||||||
baseStyles.push(a.text_md, a.font_medium)
|
baseStyles.push(a.text_md, a.font_medium)
|
||||||
|
} else if (size === 'medium') {
|
||||||
|
baseStyles.push(a.text_sm, a.font_medium)
|
||||||
} else if (size === 'small') {
|
} else if (size === 'small') {
|
||||||
baseStyles.push(a.text_sm, a.font_medium)
|
baseStyles.push(a.text_sm, a.font_medium)
|
||||||
} else if (size === 'tiny') {
|
} else if (size === 'tiny') {
|
||||||
@@ -799,6 +821,7 @@ export function ButtonIcon({
|
|||||||
size ??
|
size ??
|
||||||
(({
|
(({
|
||||||
large: 'md',
|
large: 'md',
|
||||||
|
medium: 'sm',
|
||||||
small: 'sm',
|
small: 'sm',
|
||||||
tiny: 'xs',
|
tiny: 'xs',
|
||||||
}[buttonSize || 'small'] || 'sm') as Exclude<
|
}[buttonSize || 'small'] || 'sm') as Exclude<
|
||||||
@@ -828,6 +851,7 @@ export function ButtonIcon({
|
|||||||
*/
|
*/
|
||||||
const iconContainerSize = {
|
const iconContainerSize = {
|
||||||
large: 20,
|
large: 20,
|
||||||
|
medium: 17,
|
||||||
small: 17,
|
small: 17,
|
||||||
tiny: 15,
|
tiny: 15,
|
||||||
}[buttonSize || 'small']
|
}[buttonSize || 'small']
|
||||||
@@ -841,6 +865,7 @@ export function ButtonIcon({
|
|||||||
if (buttonShape === 'default') {
|
if (buttonShape === 'default') {
|
||||||
iconNegativeMargin = {
|
iconNegativeMargin = {
|
||||||
large: -2,
|
large: -2,
|
||||||
|
medium: -2,
|
||||||
small: -2,
|
small: -2,
|
||||||
tiny: -1,
|
tiny: -1,
|
||||||
}[buttonSize || 'small']
|
}[buttonSize || 'small']
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export function Inner({children, style, header}: DialogInnerProps) {
|
|||||||
|
|
||||||
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||||
function ScrollableInner(
|
function ScrollableInner(
|
||||||
{children, contentContainerStyle, header, style, ...props},
|
{children, contentContainerStyle, header, footer, style, ...props},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
|
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
|
||||||
@@ -248,42 +248,45 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<>
|
||||||
style={[isHeightConstrained && a.flex_1, style]}
|
<ScrollView
|
||||||
contentContainerStyle={[
|
style={[isHeightConstrained && a.flex_1, style]}
|
||||||
a.pt_2xl,
|
contentContainerStyle={[
|
||||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
a.pt_2xl,
|
||||||
platform({
|
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||||
ios: a.pb_2xl,
|
platform({
|
||||||
android: {
|
ios: a.pb_2xl,
|
||||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
android: {
|
||||||
},
|
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||||
}),
|
},
|
||||||
contentContainerStyle,
|
}),
|
||||||
]}
|
contentContainerStyle,
|
||||||
ref={ref}
|
]}
|
||||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
ref={ref}
|
||||||
contentInsetAdjustmentBehavior={
|
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
contentInsetAdjustmentBehavior={
|
||||||
}
|
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
}
|
||||||
{...props}
|
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||||
bounces={isAtMaxSnapPoint}
|
{...props}
|
||||||
scrollEventThrottle={50}
|
bounces={isAtMaxSnapPoint}
|
||||||
// set drag state based on scroll on android.
|
scrollEventThrottle={50}
|
||||||
// we want to detect if it's at the top or not, so watch
|
// set drag state based on scroll on android.
|
||||||
// scrollEndDrag and momentumScrollEnd as well
|
// we want to detect if it's at the top or not, so watch
|
||||||
onScroll={android(onScroll)}
|
// scrollEndDrag and momentumScrollEnd as well
|
||||||
onScrollEndDrag={android(onScroll)}
|
onScroll={android(onScroll)}
|
||||||
onMomentumScrollEnd={android(onScroll)}
|
onScrollEndDrag={android(onScroll)}
|
||||||
keyboardShouldPersistTaps="handled"
|
onMomentumScrollEnd={android(onScroll)}
|
||||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
keyboardShouldPersistTaps="handled"
|
||||||
// on Android. fine to disable for now, because we don't have any
|
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||||
// dialogs that use this that actually scroll -sfn
|
// on Android. fine to disable for now, because we don't have any
|
||||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
// dialogs that use this that actually scroll -sfn
|
||||||
{header}
|
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||||
{children}
|
{header}
|
||||||
</ScrollView>
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -350,9 +353,11 @@ export const InnerFlatList = forwardRef<
|
|||||||
export function FlatListFooter({
|
export function FlatListFooter({
|
||||||
children,
|
children,
|
||||||
onLayout,
|
onLayout,
|
||||||
|
border = true,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onLayout?: (event: LayoutChangeEvent) => void
|
onLayout?: (event: LayoutChangeEvent) => void
|
||||||
|
border?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {bottom} = useSafeAreaInsets()
|
const {bottom} = useSafeAreaInsets()
|
||||||
@@ -373,7 +378,7 @@ export function FlatListFooter({
|
|||||||
a.bottom_0,
|
a.bottom_0,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.z_10,
|
a.z_10,
|
||||||
a.border_t,
|
border && a.border_t,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export function Inner({
|
|||||||
accessibilityLabelledBy,
|
accessibilityLabelledBy,
|
||||||
accessibilityDescribedBy,
|
accessibilityDescribedBy,
|
||||||
header,
|
header,
|
||||||
|
footer,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
}: DialogInnerProps) {
|
}: DialogInnerProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
@@ -216,6 +217,7 @@ export function Inner({
|
|||||||
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
|
{footer}
|
||||||
</DismissableLayer.DismissableLayer>
|
</DismissableLayer.DismissableLayer>
|
||||||
</View>
|
</View>
|
||||||
</FocusScope.FocusScope>
|
</FocusScope.FocusScope>
|
||||||
@@ -266,9 +268,11 @@ export const InnerFlatList = forwardRef<
|
|||||||
export function FlatListFooter({
|
export function FlatListFooter({
|
||||||
children,
|
children,
|
||||||
onLayout,
|
onLayout,
|
||||||
|
border = true,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onLayout?: (event: LayoutChangeEvent) => void
|
onLayout?: (event: LayoutChangeEvent) => void
|
||||||
|
border?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
@@ -281,7 +285,7 @@ export function FlatListFooter({
|
|||||||
a.w_full,
|
a.w_full,
|
||||||
a.z_10,
|
a.z_10,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
a.border_t,
|
border && a.border_t,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.py_md,
|
a.py_md,
|
||||||
|
|||||||
@@ -79,14 +79,18 @@ export type DialogInnerProps =
|
|||||||
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
||||||
accessibilityDescribedBy: string
|
accessibilityDescribedBy: string
|
||||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||||
|
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
|
||||||
contentContainerStyle?: StyleProp<ViewStyle>
|
contentContainerStyle?: StyleProp<ViewStyle>
|
||||||
header?: React.ReactNode
|
header?: React.ReactNode
|
||||||
|
footer?: React.ReactNode
|
||||||
}>
|
}>
|
||||||
| DialogInnerPropsBase<{
|
| DialogInnerPropsBase<{
|
||||||
label: string
|
label: string
|
||||||
accessibilityLabelledBy?: undefined
|
accessibilityLabelledBy?: undefined
|
||||||
accessibilityDescribedBy?: undefined
|
accessibilityDescribedBy?: undefined
|
||||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||||
|
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
|
||||||
contentContainerStyle?: StyleProp<ViewStyle>
|
contentContainerStyle?: StyleProp<ViewStyle>
|
||||||
header?: React.ReactNode
|
header?: React.ReactNode
|
||||||
|
footer?: React.ReactNode
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import {useGoBack} from '#/lib/hooks/useGoBack'
|
import {useGoBack} from '#/lib/hooks/useGoBack'
|
||||||
import {CenteredView} from '#/view/com/util/Views'
|
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function Error({
|
export function Error({
|
||||||
@@ -15,22 +13,20 @@ export function Error({
|
|||||||
onRetry,
|
onRetry,
|
||||||
onGoBack,
|
onGoBack,
|
||||||
hideBackButton,
|
hideBackButton,
|
||||||
sideBorders = true,
|
|
||||||
}: {
|
}: {
|
||||||
title?: string
|
title?: string
|
||||||
message?: string
|
message?: string
|
||||||
onRetry?: () => unknown
|
onRetry?: () => unknown
|
||||||
onGoBack?: () => unknown
|
onGoBack?: () => unknown
|
||||||
hideBackButton?: boolean
|
hideBackButton?: boolean
|
||||||
sideBorders?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const goBack = useGoBack(onGoBack)
|
const goBack = useGoBack(onGoBack)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredView
|
<Layout.Center
|
||||||
style={[
|
style={[
|
||||||
a.h_full_vh,
|
a.h_full_vh,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
@@ -38,8 +34,7 @@ export function Error({
|
|||||||
!gtMobile && a.justify_between,
|
!gtMobile && a.justify_between,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{paddingTop: 175, paddingBottom: 110},
|
{paddingTop: 175, paddingBottom: 110},
|
||||||
]}
|
]}>
|
||||||
sideBorders={sideBorders}>
|
|
||||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||||
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
|
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
|
||||||
<Text
|
<Text
|
||||||
@@ -58,7 +53,7 @@ export function Error({
|
|||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="primary"
|
color="primary"
|
||||||
label={_(msg`Press to retry`)}
|
label={l`Press to retry`}
|
||||||
onPress={onRetry}
|
onPress={onRetry}
|
||||||
size="large">
|
size="large">
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
@@ -70,7 +65,7 @@ export function Error({
|
|||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color={onRetry ? 'secondary' : 'primary'}
|
color={onRetry ? 'secondary' : 'primary'}
|
||||||
label={_(msg`Return to previous page`)}
|
label={l`Return to previous page`}
|
||||||
onPress={goBack}
|
onPress={goBack}
|
||||||
size="large">
|
size="large">
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
@@ -79,6 +74,6 @@ export function Error({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</CenteredView>
|
</Layout.Center>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import {Pressable, StyleSheet, View} from 'react-native'
|
import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {FocusGuards, FocusScope} from 'radix-ui/internal'
|
import {FocusGuards, FocusScope} from 'radix-ui/internal'
|
||||||
@@ -226,17 +226,21 @@ function LightboxGallery({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{img.alt ? (
|
{img.alt ? (
|
||||||
<View
|
<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.
|
||||||
style={[
|
style={[
|
||||||
a.px_4xl,
|
styles.altScroll,
|
||||||
a.py_2xl,
|
|
||||||
{
|
{
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
// @ts-expect-error web only
|
// @ts-expect-error web only
|
||||||
backdropFilter: 'blur(16px)',
|
backdropFilter: 'blur(16px)',
|
||||||
},
|
},
|
||||||
delayedFadeInAnim,
|
delayedFadeInAnim,
|
||||||
]}>
|
]}
|
||||||
|
scrollEnabled={isAltExpanded}
|
||||||
|
contentContainerStyle={[a.px_4xl, a.py_2xl]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityLabel={l`Expand alt text`}
|
accessibilityLabel={l`Expand alt text`}
|
||||||
accessibilityHint={l`If alt text is long, toggles alt text expanded state`}
|
accessibilityHint={l`If alt text is long, toggles alt text expanded state`}
|
||||||
@@ -250,7 +254,7 @@ function LightboxGallery({
|
|||||||
{img.alt}
|
{img.alt}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</ScrollView>
|
||||||
) : null}
|
) : null}
|
||||||
{imgs.length > 1 && (
|
{imgs.length > 1 && (
|
||||||
<div aria-live="polite" aria-atomic="true" style={a.sr_only}>
|
<div aria-live="polite" aria-atomic="true" style={a.sr_only}>
|
||||||
@@ -449,6 +453,14 @@ const styles = StyleSheet.create({
|
|||||||
padding: 16,
|
padding: 16,
|
||||||
boxSizing: 'border-box',
|
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: {
|
menuBtn: {
|
||||||
top: 20,
|
top: 20,
|
||||||
left: 20,
|
left: 20,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {useRef} from 'react'
|
import {useRef} from 'react'
|
||||||
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
|
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {
|
||||||
|
useSafeAreaFrame,
|
||||||
|
useSafeAreaInsets,
|
||||||
|
} from 'react-native-safe-area-context'
|
||||||
import {BlurView} from 'expo-blur'
|
import {BlurView} from 'expo-blur'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
@@ -17,10 +20,16 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
|||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
const {height: screenHeight} = useSafeAreaFrame()
|
||||||
const isMomentumScrolling = useRef(false)
|
const isMomentumScrolling = useRef(false)
|
||||||
|
|
||||||
if (!altText) return null
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -46,6 +55,7 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
|||||||
}),
|
}),
|
||||||
]}>
|
]}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
style={{maxHeight}}
|
||||||
scrollEnabled={isAltExpanded}
|
scrollEnabled={isAltExpanded}
|
||||||
onMomentumScrollBegin={() => {
|
onMomentumScrollBegin={() => {
|
||||||
isMomentumScrolling.current = true
|
isMomentumScrolling.current = true
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Animated, {
|
|||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {android, atoms as a, ios} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||||
import {type Props as IconProps} from '#/components/icons/common'
|
import {type Props as IconProps} from '#/components/icons/common'
|
||||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
|
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
|
||||||
@@ -25,7 +25,6 @@ type Props = {
|
|||||||
|
|
||||||
type Anchor = {x: number; y: number; width: number; height: number}
|
type Anchor = {x: number; y: number; width: number; height: number}
|
||||||
|
|
||||||
const MENU_WIDTH = 160
|
|
||||||
const GAP = 6
|
const GAP = 6
|
||||||
const CARD_BG = '#000000'
|
const CARD_BG = '#000000'
|
||||||
const CARD_BORDER = '#232e3e'
|
const CARD_BORDER = '#232e3e'
|
||||||
@@ -124,9 +123,8 @@ function MenuCard({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
a.absolute,
|
a.absolute,
|
||||||
|
a.self_start,
|
||||||
styles.card,
|
styles.card,
|
||||||
android({alignSelf: 'flex-start'}),
|
|
||||||
ios({width: MENU_WIDTH}),
|
|
||||||
{
|
{
|
||||||
top: anchor.y + anchor.height + GAP,
|
top: anchor.y + anchor.height + GAP,
|
||||||
left: anchor.x,
|
left: anchor.x,
|
||||||
@@ -186,7 +184,6 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
},
|
},
|
||||||
itemText: {
|
itemText: {
|
||||||
flex: 1,
|
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
lineHeight: 19.5,
|
lineHeight: 19.5,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
|
import {BlurView} from 'expo-blur'
|
||||||
import {atoms as a} from '#/alf'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
count: number
|
count: number
|
||||||
@@ -14,26 +13,38 @@ const GAP = 5
|
|||||||
export function PagerDots({count, activeIndex}: Props) {
|
export function PagerDots({count, activeIndex}: Props) {
|
||||||
if (count <= 1) return null
|
if (count <= 1) return null
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
|
<View style={styles.root}>
|
||||||
{Array.from({length: count}).map((_, i) => {
|
<BlurView intensity={20} tint="dark" style={styles.inner}>
|
||||||
const isActive = i === activeIndex
|
{Array.from({length: count}).map((_, i) => {
|
||||||
return (
|
const isActive = i === activeIndex
|
||||||
<View
|
return (
|
||||||
key={i}
|
<View
|
||||||
style={[
|
key={i}
|
||||||
isActive ? styles.active : styles.inactive,
|
style={[
|
||||||
isActive ? styles.activeDot : styles.inactiveDot,
|
isActive ? styles.active : styles.inactive,
|
||||||
]}
|
isActive ? styles.activeDot : styles.inactiveDot,
|
||||||
/>
|
]}
|
||||||
)
|
/>
|
||||||
})}
|
)
|
||||||
|
})}
|
||||||
|
</BlurView>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: {
|
root: {
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
inner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
gap: GAP,
|
gap: GAP,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
},
|
},
|
||||||
activeDot: {
|
activeDot: {
|
||||||
width: ACTIVE,
|
width: ACTIVE,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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)',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -39,6 +39,7 @@ import {type Dimensions} from '#/lib/media/types'
|
|||||||
import {useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
import {setSystemUITheme} from '#/alf/util/systemUI'
|
import {setSystemUITheme} from '#/alf/util/systemUI'
|
||||||
import {type Lightbox} from '#/components/Lightbox/state'
|
import {type Lightbox} from '#/components/Lightbox/state'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_IOS} from '#/env'
|
import {IS_IOS} from '#/env'
|
||||||
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
|
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
|
||||||
import {Footer} from '../chrome/Footer'
|
import {Footer} from '../chrome/Footer'
|
||||||
@@ -90,6 +91,9 @@ export default function ImageViewRoot({
|
|||||||
'use no memo'
|
'use no memo'
|
||||||
const ref = useAnimatedRef<View>()
|
const ref = useAnimatedRef<View>()
|
||||||
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
|
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
|
||||||
|
// Lives here rather than in ImageView so it survives the remount
|
||||||
|
// when the orientation-based key below changes on rotation.
|
||||||
|
const [imageIndex, setImageIndex] = useState(nextLightbox?.index ?? 0)
|
||||||
const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(
|
const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(
|
||||||
'portrait',
|
'portrait',
|
||||||
)
|
)
|
||||||
@@ -100,6 +104,7 @@ export default function ImageViewRoot({
|
|||||||
|
|
||||||
if (!activeLightbox && nextLightbox) {
|
if (!activeLightbox && nextLightbox) {
|
||||||
setActiveLightbox(nextLightbox)
|
setActiveLightbox(nextLightbox)
|
||||||
|
setImageIndex(nextLightbox.index)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -192,6 +197,8 @@ export default function ImageViewRoot({
|
|||||||
<ImageView
|
<ImageView
|
||||||
key={activeLightbox.id + '-' + orientation}
|
key={activeLightbox.id + '-' + orientation}
|
||||||
lightbox={activeLightbox}
|
lightbox={activeLightbox}
|
||||||
|
imageIndex={imageIndex}
|
||||||
|
setImageIndex={setImageIndex}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
onRequestClose={onRequestClose}
|
onRequestClose={onRequestClose}
|
||||||
onPressSave={onPressSave}
|
onPressSave={onPressSave}
|
||||||
@@ -209,6 +216,8 @@ export default function ImageViewRoot({
|
|||||||
|
|
||||||
function ImageView({
|
function ImageView({
|
||||||
lightbox,
|
lightbox,
|
||||||
|
imageIndex,
|
||||||
|
setImageIndex,
|
||||||
orientation,
|
orientation,
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
onPressSave,
|
onPressSave,
|
||||||
@@ -219,6 +228,8 @@ function ImageView({
|
|||||||
thumbRects,
|
thumbRects,
|
||||||
}: {
|
}: {
|
||||||
lightbox: Lightbox
|
lightbox: Lightbox
|
||||||
|
imageIndex: number
|
||||||
|
setImageIndex: React.Dispatch<React.SetStateAction<number>>
|
||||||
orientation: 'portrait' | 'landscape'
|
orientation: 'portrait' | 'landscape'
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
onPressSave: (uri: string) => void
|
onPressSave: (uri: string) => void
|
||||||
@@ -228,11 +239,14 @@ function ImageView({
|
|||||||
openProgress: SharedValue<number>
|
openProgress: SharedValue<number>
|
||||||
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
||||||
}) {
|
}) {
|
||||||
const {images, index: initialImageIndex} = lightbox
|
const {images, metricsContext} = lightbox
|
||||||
|
// Capture at mount: after a rotation remount this is the preserved
|
||||||
|
// current index, so the pager re-opens on the same image.
|
||||||
|
const [initialImageIndex] = useState(imageIndex)
|
||||||
|
const ax = useAnalytics()
|
||||||
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
|
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
|
||||||
const [isScaled, setIsScaled] = useState(false)
|
const [isScaled, setIsScaled] = useState(false)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [imageIndex, setImageIndex] = useState(initialImageIndex)
|
|
||||||
const [showControls, setShowControls] = useState(true)
|
const [showControls, setShowControls] = useState(true)
|
||||||
const [isAltExpanded, setIsAltExpanded] = useState(false)
|
const [isAltExpanded, setIsAltExpanded] = useState(false)
|
||||||
const dismissSwipeTranslateY = useSharedValue(0)
|
const dismissSwipeTranslateY = useSharedValue(0)
|
||||||
@@ -377,7 +391,21 @@ function ImageView({
|
|||||||
scrollEnabled={!isScaled}
|
scrollEnabled={!isScaled}
|
||||||
initialPage={initialImageIndex}
|
initialPage={initialImageIndex}
|
||||||
onPageSelected={e => {
|
onPageSelected={e => {
|
||||||
setImageIndex(e.nativeEvent.position)
|
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
|
||||||
|
})
|
||||||
setIsScaled(false)
|
setIsScaled(false)
|
||||||
}}
|
}}
|
||||||
onPageScrollStateChanged={e => {
|
onPageScrollStateChanged={e => {
|
||||||
|
|||||||
@@ -11,10 +11,20 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
|||||||
import {useHotkeysContext} from '#/lib/hotkeys'
|
import {useHotkeysContext} from '#/lib/hotkeys'
|
||||||
import {type ImageSource} from '#/components/Lightbox/types'
|
import {type ImageSource} from '#/components/Lightbox/types'
|
||||||
|
|
||||||
|
export type LightboxMetricsContext = {
|
||||||
|
layout: 'single' | 'grid' | 'carousel'
|
||||||
|
postUri: string
|
||||||
|
postAuthorDid: string
|
||||||
|
feedDescriptor?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type Lightbox = {
|
export type Lightbox = {
|
||||||
id: string
|
id: string
|
||||||
images: ImageSource[]
|
images: ImageSource[]
|
||||||
index: number
|
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<{
|
const LightboxContext = createContext<{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type TargetedEvent,
|
type TargetedEvent,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {
|
import {
|
||||||
type LinkProps as RNLinkProps,
|
type LinkProps as RNLinkProps,
|
||||||
StackActions,
|
StackActions,
|
||||||
@@ -26,11 +27,14 @@ import {
|
|||||||
linkRequiresWarning,
|
linkRequiresWarning,
|
||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
import {useModalControls} from '#/state/modals'
|
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 {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
|
||||||
import {Button, type ButtonProps} from '#/components/Button'
|
import {Button, type ButtonProps} from '#/components/Button'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
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 {Text, type TextProps} from '#/components/Typography'
|
||||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import {router} from '#/routes'
|
import {router} from '#/routes'
|
||||||
import {useGlobalDialogsControlContext} from './dialogs/Context'
|
import {useGlobalDialogsControlContext} from './dialogs/Context'
|
||||||
|
|
||||||
@@ -86,6 +90,13 @@ type BaseLinkProps = {
|
|||||||
*/
|
*/
|
||||||
shouldProxy?: boolean
|
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
|
* Web only
|
||||||
*/
|
*/
|
||||||
@@ -275,11 +286,19 @@ export function useLink({
|
|||||||
[outerOnLongPress, handleLongPress, shareOnLongPress],
|
[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 {
|
return {
|
||||||
isExternal,
|
isExternal,
|
||||||
href,
|
href,
|
||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
|
openExternally,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,9 +324,10 @@ export function Link({
|
|||||||
download,
|
download,
|
||||||
shouldProxy,
|
shouldProxy,
|
||||||
overridePresentation,
|
overridePresentation,
|
||||||
|
peek,
|
||||||
...rest
|
...rest
|
||||||
}: LinkProps) {
|
}: LinkProps) {
|
||||||
const {href, isExternal, onPress, onLongPress} = useLink({
|
const {href, isExternal, onPress, onLongPress, openExternally} = useLink({
|
||||||
to,
|
to,
|
||||||
displayText: typeof children === 'string' ? children : '',
|
displayText: typeof children === 'string' ? children : '',
|
||||||
action,
|
action,
|
||||||
@@ -317,7 +337,10 @@ export function Link({
|
|||||||
overridePresentation,
|
overridePresentation,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
// Peek is iOS-only and only makes sense for external web links.
|
||||||
|
const peekEnabled = Boolean(peek && IS_IOS && isExternal)
|
||||||
|
|
||||||
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
{...rest}
|
{...rest}
|
||||||
style={[a.justify_start, rest.style]}
|
style={[a.justify_start, rest.style]}
|
||||||
@@ -325,7 +348,11 @@ export function Link({
|
|||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
href={href}
|
href={href}
|
||||||
onPress={download ? undefined : onPress}
|
onPress={download ? undefined : onPress}
|
||||||
onLongPress={onLongPress}
|
// 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}
|
||||||
{...web({
|
{...web({
|
||||||
hrefAttrs: {
|
hrefAttrs: {
|
||||||
target: download ? undefined : isExternal ? 'blank' : undefined,
|
target: download ? undefined : isExternal ? 'blank' : undefined,
|
||||||
@@ -340,6 +367,74 @@ export function Link({
|
|||||||
{children}
|
{children}
|
||||||
</Button>
|
</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<
|
export type InlineLinkProps = React.PropsWithChildren<
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ let ListMaybePlaceholder = ({
|
|||||||
message={errorMessage ?? _(msg`Something went wrong!`)}
|
message={errorMessage ?? _(msg`Something went wrong!`)}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
onGoBack={onGoBack}
|
onGoBack={onGoBack}
|
||||||
sideBorders={sideBorders}
|
|
||||||
hideBackButton={hideBackButton}
|
hideBackButton={hideBackButton}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -226,7 +225,6 @@ let ListMaybePlaceholder = ({
|
|||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
onGoBack={onGoBack}
|
onGoBack={onGoBack}
|
||||||
hideBackButton={hideBackButton}
|
hideBackButton={hideBackButton}
|
||||||
sideBorders={sideBorders}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
|
import {
|
||||||
|
AppBskyEmbedGallery,
|
||||||
|
type AppBskyEmbedImages,
|
||||||
|
type AppBskyFeedDefs,
|
||||||
|
} from '@atproto/api'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {shareImageModal} from '#/lib/media/manip'
|
import {shareImageModal} from '#/lib/media/manip'
|
||||||
@@ -47,6 +51,34 @@ export function Embed({
|
|||||||
)}
|
)}
|
||||||
</Outer>
|
</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') {
|
} else if (e.type === 'link') {
|
||||||
if (!e.view.external.thumb) return null
|
if (!e.view.external.thumb) return null
|
||||||
if (!isGifEmbed(e.view.external.uri)) return null
|
if (!isGifEmbed(e.view.external.uri)) return null
|
||||||
@@ -95,10 +127,12 @@ export function ImageItem({
|
|||||||
thumbnail,
|
thumbnail,
|
||||||
alt,
|
alt,
|
||||||
children,
|
children,
|
||||||
|
maxWidth = 100,
|
||||||
}: {
|
}: {
|
||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
alt?: string
|
alt?: string
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
maxWidth?: number
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
@@ -109,7 +143,7 @@ export function ImageItem({
|
|||||||
{backgroundColor: 'black'},
|
{backgroundColor: 'black'},
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.aspect_square,
|
a.aspect_square,
|
||||||
{maxWidth: 100},
|
{maxWidth},
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
]}
|
]}
|
||||||
accessibilityLabel={alt}
|
accessibilityLabel={alt}
|
||||||
@@ -120,7 +154,7 @@ export function ImageItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
|
<View style={[a.flex_grow, a.relative, a.aspect_square, {maxWidth}]}>
|
||||||
<Image
|
<Image
|
||||||
key={thumbnail}
|
key={thumbnail}
|
||||||
source={{uri: thumbnail}}
|
source={{uri: thumbnail}}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
|
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
|
||||||
<Text style={[a.text_xs, a.leading_tight]}>
|
<Text style={[a.text_xs, a.leading_tight]}>
|
||||||
<Trans>Follows You</Trans>
|
<Trans>Follows you</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 {useCallback, useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||||
@@ -51,17 +51,19 @@ export const ExternalEmbed = ({
|
|||||||
}, [link.uri, externalEmbedPrefs])
|
}, [link.uri, externalEmbedPrefs])
|
||||||
const hasMedia = Boolean(imageUri || embedPlayerParams)
|
const hasMedia = Boolean(imageUri || embedPlayerParams)
|
||||||
|
|
||||||
const onPress = useCallback(() => {
|
const onPress = () => {
|
||||||
playHaptic('Light')
|
playHaptic('Light')
|
||||||
onOpen?.()
|
onOpen?.()
|
||||||
}, [playHaptic, onOpen])
|
}
|
||||||
|
|
||||||
const onShareExternal = useCallback(() => {
|
const onShareExternal = IS_NATIVE
|
||||||
if (link.uri && IS_NATIVE) {
|
? () => {
|
||||||
playHaptic('Heavy')
|
if (link.uri) {
|
||||||
shareUrl(link.uri)
|
playHaptic('Heavy')
|
||||||
}
|
void shareUrl(link.uri)
|
||||||
}, [link.uri, playHaptic])
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (
|
if (
|
||||||
embedPlayerParams?.source === 'tenor' ||
|
embedPlayerParams?.source === 'tenor' ||
|
||||||
@@ -86,6 +88,8 @@ export const ExternalEmbed = ({
|
|||||||
label={link.title || _(msg`Open link to ${niceUrl}`)}
|
label={link.title || _(msg`Open link to ${niceUrl}`)}
|
||||||
to={link.uri}
|
to={link.uri}
|
||||||
shouldProxy={true}
|
shouldProxy={true}
|
||||||
|
peek
|
||||||
|
style={[a.rounded_md]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onLongPress={onShareExternal}>
|
onLongPress={onShareExternal}>
|
||||||
{({hovered}) => (
|
{({hovered}) => (
|
||||||
@@ -97,6 +101,7 @@ export const ExternalEmbed = ({
|
|||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.border,
|
a.border,
|
||||||
|
t.atoms.bg,
|
||||||
style,
|
style,
|
||||||
hovered
|
hovered
|
||||||
? t.atoms.border_contrast_high
|
? t.atoms.border_contrast_high
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,12 +2,16 @@ import {useRef} from 'react'
|
|||||||
import {InteractionManager, View} from 'react-native'
|
import {InteractionManager, View} from 'react-native'
|
||||||
import {type AnimatedRef} from 'react-native-reanimated'
|
import {type AnimatedRef} from 'react-native-reanimated'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
|
import {AppBskyEmbedGallery, type AppBskyEmbedImages} from '@atproto/api'
|
||||||
|
|
||||||
import {atoms as a, tokens} from '#/alf'
|
import {atoms as a, tokens} from '#/alf'
|
||||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||||
import {Gallery} from '#/components/images/Gallery'
|
import {Gallery} from '#/components/images/Gallery'
|
||||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||||
import {useLightboxControls} from '#/components/Lightbox/state'
|
import {
|
||||||
|
type LightboxMetricsContext,
|
||||||
|
useLightboxControls,
|
||||||
|
} from '#/components/Lightbox/state'
|
||||||
import {type Dimensions} from '#/components/Lightbox/types'
|
import {type Dimensions} from '#/components/Lightbox/types'
|
||||||
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||||
@@ -15,16 +19,43 @@ import {useAnalytics} from '#/analytics'
|
|||||||
import {type EmbedType} from '#/types/bsky/post'
|
import {type EmbedType} from '#/types/bsky/post'
|
||||||
import {type CommonProps} from './types'
|
import {type CommonProps} from './types'
|
||||||
|
|
||||||
|
const MAX_GRID_IMAGES = 4
|
||||||
|
|
||||||
export function ImageEmbed({
|
export function ImageEmbed({
|
||||||
embed,
|
embed,
|
||||||
...rest
|
...rest
|
||||||
}: CommonProps & {
|
}: CommonProps & {
|
||||||
embed: EmbedType<'images'>
|
embed: EmbedType<'images'> | EmbedType<'gallery'>
|
||||||
}) {
|
}) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {openLightbox} = useLightboxControls()
|
const {openLightbox} = useLightboxControls()
|
||||||
const {images} = embed.view
|
const images: AppBskyEmbedImages.ViewImage[] =
|
||||||
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
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
|
||||||
|
|
||||||
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
|
// 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.
|
// ref + dims that a tap would — keeps the lightbox's return animation intact.
|
||||||
@@ -43,6 +74,14 @@ export function ImageEmbed({
|
|||||||
refs: AnimatedRef<any>[],
|
refs: AnimatedRef<any>[],
|
||||||
fetchedDims: (Dimensions | null)[],
|
fetchedDims: (Dimensions | null)[],
|
||||||
) => {
|
) => {
|
||||||
|
if (postContext) {
|
||||||
|
ax.metric('post:photoEmbed:open', {
|
||||||
|
layout,
|
||||||
|
fromImage: index + 1,
|
||||||
|
totalImages: images.length,
|
||||||
|
...postContext,
|
||||||
|
})
|
||||||
|
}
|
||||||
openLightbox({
|
openLightbox({
|
||||||
images: items.map((item, i) => ({
|
images: items.map((item, i) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -53,6 +92,7 @@ export function ImageEmbed({
|
|||||||
type: 'image',
|
type: 'image',
|
||||||
})),
|
})),
|
||||||
index,
|
index,
|
||||||
|
metricsContext,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const onPressIn = (_: number) => {
|
const onPressIn = (_: number) => {
|
||||||
@@ -109,7 +149,7 @@ export function ImageEmbed({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (galleryEnabled) {
|
if (useExpandedLayout) {
|
||||||
return (
|
return (
|
||||||
<View style={[a.mt_sm, rest.style]}>
|
<View style={[a.mt_sm, rest.style]}>
|
||||||
<Gallery
|
<Gallery
|
||||||
@@ -118,6 +158,7 @@ export function ImageEmbed({
|
|||||||
onPressIn={onPressIn}
|
onPressIn={onPressIn}
|
||||||
viewContext={rest.viewContext}
|
viewContext={rest.viewContext}
|
||||||
isWithinQuote={rest.isWithinQuote}
|
isWithinQuote={rest.isWithinQuote}
|
||||||
|
metricsPostContext={postContext}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -130,6 +171,7 @@ export function ImageEmbed({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onPressIn={onPressIn}
|
onPressIn={onPressIn}
|
||||||
viewContext={rest.viewContext}
|
viewContext={rest.viewContext}
|
||||||
|
isWithinQuote={rest.isWithinQuote}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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_low,
|
||||||
|
{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,13 +1,10 @@
|
|||||||
import {Fragment, type ReactNode} from 'react'
|
import {Fragment, type ReactNode} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
|
||||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {StandardSite} from '#/components/icons/community/StandardSite'
|
|
||||||
import {InlineLinkText} from '#/components/Link'
|
|
||||||
import {
|
import {
|
||||||
matchStandardSitePublisher,
|
matchStandardSitePublisher,
|
||||||
matchStandardSitePublisherByUri,
|
matchStandardSitePublisherByUri,
|
||||||
@@ -18,19 +15,15 @@ import {
|
|||||||
isStandardSitePublicationUri,
|
isStandardSitePublicationUri,
|
||||||
} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
|
||||||
|
|
||||||
export function StandardSiteMetaRow({
|
export function StandardSiteMetaRow({
|
||||||
type = 'document',
|
type = 'document',
|
||||||
preview,
|
|
||||||
view,
|
view,
|
||||||
}: ssTypes.CommonProps &
|
}: ssTypes.CommonProps &
|
||||||
ssTypes.PreviewProps & {
|
ssTypes.PreviewProps & {
|
||||||
type?: 'document' | 'publication'
|
type?: 'document' | 'publication'
|
||||||
}) {
|
}) {
|
||||||
const ax = useAnalytics()
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
|
||||||
const highlightedPublisher = !!matchStandardSitePublisher(view)
|
const highlightedPublisher = !!matchStandardSitePublisher(view)
|
||||||
const didsFromRecords =
|
const didsFromRecords =
|
||||||
view.associatedRefs
|
view.associatedRefs
|
||||||
@@ -47,7 +40,11 @@ export function StandardSiteMetaRow({
|
|||||||
: undefined
|
: undefined
|
||||||
const articleDomain = toNiceDomain(view.uri)
|
const articleDomain = toNiceDomain(view.uri)
|
||||||
const articlePublisher = matchStandardSitePublisherByUri(view.uri)
|
const articlePublisher = matchStandardSitePublisherByUri(view.uri)
|
||||||
const DomainIcon = articlePublisher?.Icon || StandardSite
|
const domainHandleMatch =
|
||||||
|
authorProfile?.handle &&
|
||||||
|
(articleDomain === authorProfile.handle ||
|
||||||
|
articleDomain.endsWith(`.${authorProfile.handle}`))
|
||||||
|
const DomainIcon = articlePublisher?.Icon
|
||||||
const metaTextStyle = [
|
const metaTextStyle = [
|
||||||
a.text_xs,
|
a.text_xs,
|
||||||
a.leading_tight,
|
a.leading_tight,
|
||||||
@@ -56,7 +53,7 @@ export function StandardSiteMetaRow({
|
|||||||
|
|
||||||
const items: {key: string; node: ReactNode}[] = []
|
const items: {key: string; node: ReactNode}[] = []
|
||||||
|
|
||||||
if (!highlightedPublisher) {
|
if (!highlightedPublisher && !domainHandleMatch) {
|
||||||
items.push({
|
items.push({
|
||||||
key: 'domain',
|
key: 'domain',
|
||||||
node: (
|
node: (
|
||||||
@@ -77,25 +74,7 @@ export function StandardSiteMetaRow({
|
|||||||
key: 'author',
|
key: 'author',
|
||||||
node: (
|
node: (
|
||||||
<Text numberOfLines={1} style={[metaTextStyle]}>
|
<Text numberOfLines={1} style={[metaTextStyle]}>
|
||||||
<Trans>
|
<Trans>by @{authorProfile.handle}</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>
|
</Text>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {plural} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {useCallOnce} from '#/lib/once'
|
|
||||||
import {shareUrl} from '#/lib/sharing'
|
import {shareUrl} from '#/lib/sharing'
|
||||||
import {niceDate} from '#/lib/strings/time'
|
import {niceDate} from '#/lib/strings/time'
|
||||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||||
@@ -16,6 +15,7 @@ import {Divider} from '#/components/Divider'
|
|||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
|
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
|
||||||
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
|
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
|
||||||
|
import {StandardSite} from '#/components/icons/community/StandardSite'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
|
import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
|
||||||
@@ -80,13 +80,15 @@ export const StandardSiteEmbed = ({
|
|||||||
onEmbedInteractionCallback?.()
|
onEmbedInteractionCallback?.()
|
||||||
ax.metric('embed:standardSite:article:press', {url: view.uri})
|
ax.metric('embed:standardSite:article:press', {url: view.uri})
|
||||||
}
|
}
|
||||||
const onLongPress = () => {
|
const onLongPress = IS_NATIVE
|
||||||
if (view.uri && IS_NATIVE) {
|
? () => {
|
||||||
playHaptic('Heavy')
|
if (view.uri) {
|
||||||
shareUrl(view.uri)
|
playHaptic('Heavy')
|
||||||
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
|
void shareUrl(view.uri)
|
||||||
}
|
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
const onPressPublication = () => {
|
const onPressPublication = () => {
|
||||||
playHaptic('Light')
|
playHaptic('Light')
|
||||||
onEmbedInteractionCallback?.()
|
onEmbedInteractionCallback?.()
|
||||||
@@ -94,21 +96,17 @@ export const StandardSiteEmbed = ({
|
|||||||
url: view.source?.uri || '',
|
url: view.source?.uri || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const onLongPressPublication = () => {
|
const onLongPressPublication = IS_NATIVE
|
||||||
if (view.source?.uri && IS_NATIVE) {
|
? () => {
|
||||||
playHaptic('Heavy')
|
if (view.source?.uri) {
|
||||||
shareUrl(view.source.uri)
|
playHaptic('Heavy')
|
||||||
ax.metric('embed:standardSite:publication:longPress', {
|
void shareUrl(view.source.uri)
|
||||||
url: view.source.uri,
|
ax.metric('embed:standardSite:publication:longPress', {
|
||||||
})
|
url: view.source.uri,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
useCallOnce(() => {
|
: undefined
|
||||||
if (!preview) {
|
|
||||||
ax.metric('embed:standardSite:view', {url: view.uri})
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (isStandardPublication) {
|
if (isStandardPublication) {
|
||||||
return (
|
return (
|
||||||
@@ -129,7 +127,6 @@ export const StandardSiteEmbed = ({
|
|||||||
style={[
|
style={[
|
||||||
a.flex_col,
|
a.flex_col,
|
||||||
a.rounded_lg,
|
a.rounded_lg,
|
||||||
a.overflow_hidden,
|
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.border,
|
a.border,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
@@ -137,126 +134,149 @@ export const StandardSiteEmbed = ({
|
|||||||
preview && a.pointer_events_none,
|
preview && a.pointer_events_none,
|
||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
|
{/*
|
||||||
|
* The article is an in-flow `Link` wrapping the content so the iOS peek
|
||||||
|
* menu has something to lift. The publication footer below lives outside
|
||||||
|
* it - it has its own link (to the publication) and Subscribe button, so
|
||||||
|
* it can't be nested inside the article's peek.
|
||||||
|
*/}
|
||||||
<Link
|
<Link
|
||||||
|
peek
|
||||||
shouldProxy
|
shouldProxy
|
||||||
to={view.uri}
|
to={view.uri}
|
||||||
label={view.title || l`Open link to ${niceUrl}`}
|
label={view.title || l`Open link to ${niceUrl}`}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onLongPress={onLongPress}
|
onLongPress={onLongPress}
|
||||||
style={[a.absolute, a.inset_0, a.z_10]}
|
style={[a.rounded_lg]}
|
||||||
{...web({
|
{...web({
|
||||||
onMouseEnter: onInteract,
|
onMouseEnter: onInteract,
|
||||||
onMouseLeave: onInteractOut,
|
onMouseLeave: onInteractOut,
|
||||||
})}
|
})}
|
||||||
onFocus={onInteract}
|
onFocus={onInteract}
|
||||||
onBlur={onInteractOut}>
|
onBlur={onInteractOut}>
|
||||||
<></>
|
{() => (
|
||||||
</Link>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.w_full,
|
|
||||||
a.z_10,
|
|
||||||
a.pointer_events_none,
|
|
||||||
interacted && [t.atoms.bg_contrast_25],
|
|
||||||
]}>
|
|
||||||
{imageUri ? (
|
|
||||||
<Image
|
|
||||||
style={[a.aspect_card]}
|
|
||||||
source={{uri: imageUri}}
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
loading="lazy"
|
|
||||||
useAppleWebpCodec
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_1,
|
|
||||||
a.pt_sm,
|
|
||||||
hasMedia && a.border_t,
|
|
||||||
interacted
|
|
||||||
? t.atoms.border_contrast_high
|
|
||||||
: t.atoms.border_contrast_low,
|
|
||||||
{gap: 3},
|
|
||||||
isStandard && a.pt_md,
|
|
||||||
]}>
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.pb_xs,
|
a.w_full,
|
||||||
a.px_md,
|
a.overflow_hidden,
|
||||||
{gap: 3},
|
// The container can't clip (overflow_hidden there breaks the peek
|
||||||
isStandard && [{gap: 5}, a.pb_sm],
|
// lift animation), so each half rounds its own outer corners to
|
||||||
|
// match the container border. The article rounds the top, plus the
|
||||||
|
// bottom when there's no footer beneath it.
|
||||||
|
{
|
||||||
|
borderTopLeftRadius: a.rounded_lg.borderRadius,
|
||||||
|
borderTopRightRadius: a.rounded_lg.borderRadius,
|
||||||
|
},
|
||||||
|
!view.source && {
|
||||||
|
borderBottomLeftRadius: a.rounded_lg.borderRadius,
|
||||||
|
borderBottomRightRadius: a.rounded_lg.borderRadius,
|
||||||
|
},
|
||||||
|
interacted ? t.atoms.bg_contrast_25 : t.atoms.bg,
|
||||||
]}>
|
]}>
|
||||||
<Text
|
{imageUri ? (
|
||||||
emoji
|
<Image
|
||||||
numberOfLines={3}
|
style={[a.aspect_card]}
|
||||||
style={[
|
source={{uri: imageUri}}
|
||||||
a.text_md,
|
accessibilityIgnoresInvertColors
|
||||||
a.font_semi_bold,
|
loading="lazy"
|
||||||
a.leading_snug,
|
useAppleWebpCodec
|
||||||
isStandard && [a.text_lg, a.font_bold],
|
/>
|
||||||
]}>
|
|
||||||
{view.title}
|
|
||||||
</Text>
|
|
||||||
{view.description ? (
|
|
||||||
<Text
|
|
||||||
emoji
|
|
||||||
numberOfLines={view.thumb ? 2 : 4}
|
|
||||||
style={[a.text_sm, a.leading_snug]}>
|
|
||||||
{view.description}
|
|
||||||
</Text>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
||||||
{isStandard && (view.createdAt || view.readingTime) && (
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.pt_sm,
|
||||||
|
hasMedia && a.border_t,
|
||||||
|
interacted
|
||||||
|
? t.atoms.border_contrast_high
|
||||||
|
: t.atoms.border_contrast_low,
|
||||||
|
{gap: 3},
|
||||||
|
isStandard && a.pt_md,
|
||||||
|
]}>
|
||||||
<View
|
<View
|
||||||
style={[a.flex_row, a.align_center, a.gap_md, {paddingTop: 2}]}>
|
style={[
|
||||||
{view.createdAt && (
|
a.pb_xs,
|
||||||
|
a.px_md,
|
||||||
|
{gap: 3},
|
||||||
|
isStandard && [{gap: 5}, a.pb_sm],
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
emoji
|
||||||
|
numberOfLines={3}
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.font_semi_bold,
|
||||||
|
a.leading_snug,
|
||||||
|
isStandard && [a.text_lg, a.font_bold],
|
||||||
|
]}>
|
||||||
|
{view.title}
|
||||||
|
</Text>
|
||||||
|
{view.description ? (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
emoji
|
||||||
a.text_xs,
|
numberOfLines={view.thumb ? 2 : 4}
|
||||||
a.leading_snug,
|
style={[a.text_sm, a.leading_snug]}>
|
||||||
t.atoms.text_contrast_medium,
|
{view.description}
|
||||||
]}>
|
|
||||||
{niceDate(i18n, view.createdAt, 'long', 'none')}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
) : undefined}
|
||||||
{view.readingTime && (
|
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
|
{isStandard && (view.createdAt || view.readingTime) && (
|
||||||
<Clock size="xs" style={t.atoms.text_contrast_medium} />
|
<View
|
||||||
<Text
|
style={[
|
||||||
style={[
|
a.flex_row,
|
||||||
a.text_xs,
|
a.align_center,
|
||||||
a.leading_snug,
|
a.gap_md,
|
||||||
t.atoms.text_contrast_medium,
|
{paddingTop: 2},
|
||||||
]}>
|
]}>
|
||||||
{l({
|
{view.createdAt && (
|
||||||
message: plural(view.readingTime, {
|
<Text
|
||||||
one: '#m',
|
style={[
|
||||||
other: '#m',
|
a.text_xs,
|
||||||
}),
|
a.leading_snug,
|
||||||
comment: `How long it takes to read an article, in minutes. Displayed in a short form, e.g. "5m" for 5 minutes.`,
|
t.atoms.text_contrast_medium,
|
||||||
})}
|
]}>
|
||||||
</Text>
|
{niceDate(i18n, view.createdAt, 'long', 'none')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{view.readingTime && (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
|
||||||
|
<Clock size="xs" style={t.atoms.text_contrast_medium} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{l({
|
||||||
|
message: plural(view.readingTime, {
|
||||||
|
one: '#m',
|
||||||
|
other: '#m',
|
||||||
|
}),
|
||||||
|
comment: `How long it takes to read an article, in minutes. Displayed in a short form, e.g. "5m" for 5 minutes.`,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!view.source && (
|
||||||
|
<View style={[a.px_md]}>
|
||||||
|
<Divider />
|
||||||
|
<View style={[a.py_sm]}>
|
||||||
|
<StandardSiteMetaRow preview={preview} view={view} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{!view.source && (
|
|
||||||
<View style={[a.px_md]}>
|
|
||||||
<Divider />
|
|
||||||
<View style={[a.py_sm]}>
|
|
||||||
<StandardSiteMetaRow preview={preview} view={view} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</Link>
|
||||||
|
|
||||||
{view.source && (
|
{view.source && (
|
||||||
<View style={[a.z_20]}>
|
<View>
|
||||||
<Divider />
|
<Divider />
|
||||||
<PublicationFooter
|
<PublicationFooter
|
||||||
preview={preview}
|
preview={preview}
|
||||||
@@ -356,6 +376,7 @@ export function PublicationCard({
|
|||||||
/>
|
/>
|
||||||
<View style={[a.flex_1, a.gap_2xs]}>
|
<View style={[a.flex_1, a.gap_2xs]}>
|
||||||
<Text
|
<Text
|
||||||
|
emoji
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={[
|
style={[
|
||||||
a.text_md,
|
a.text_md,
|
||||||
@@ -386,7 +407,7 @@ export function PublicationCard({
|
|||||||
<View style={[a.pointer_events_none]}>
|
<View style={[a.pointer_events_none]}>
|
||||||
{view.description && (
|
{view.description && (
|
||||||
<View style={[a.pt_sm]}>
|
<View style={[a.pt_sm]}>
|
||||||
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
|
<Text emoji style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
|
||||||
{view.description}
|
{view.description}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -426,6 +447,26 @@ export function SubscribeButton({
|
|||||||
? l`Subscribe on ${highlightedPublisher.name}`
|
? l`Subscribe on ${highlightedPublisher.name}`
|
||||||
: l`View publication`
|
: 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
|
if (!view.source) return null
|
||||||
|
|
||||||
const publicationTitle = view.source.title
|
const publicationTitle = view.source.title
|
||||||
@@ -451,52 +492,60 @@ export function SubscribeButton({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLongPress = () => {
|
const onLongPress = IS_NATIVE
|
||||||
if (view.source?.uri && IS_NATIVE) {
|
? () => {
|
||||||
playHaptic('Heavy')
|
if (view.source?.uri) {
|
||||||
shareUrl(view.source.uri)
|
playHaptic('Heavy')
|
||||||
if (highlightedPublisher) {
|
void shareUrl(view.source.uri)
|
||||||
ax.metric('embed:standardSite:subscribe:longPress', {
|
if (highlightedPublisher) {
|
||||||
url: view.source?.uri || '',
|
ax.metric('embed:standardSite:subscribe:longPress', {
|
||||||
})
|
url: view.source?.uri || '',
|
||||||
} else {
|
})
|
||||||
ax.metric('embed:standardSite:publicationCta:longPress', {
|
} else {
|
||||||
url: view.source?.uri || '',
|
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 (
|
return (
|
||||||
<StandardSiteThemeProvider view={view}>
|
<StandardSiteThemeProvider view={view}>{button}</StandardSiteThemeProvider>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,8 +558,9 @@ function PublicationIcon({
|
|||||||
interacted?: boolean
|
interacted?: boolean
|
||||||
themeColors: ssTypes.ThemeColors
|
themeColors: ssTypes.ThemeColors
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
if (!view.source) return null
|
if (!view.source) return null
|
||||||
return view.source?.icon ? (
|
const icon = view.source?.icon ? (
|
||||||
<View>
|
<View>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
noBorder
|
noBorder
|
||||||
@@ -541,6 +591,29 @@ function PublicationIcon({
|
|||||||
<MediaInsetBorder opaque style={[a.rounded_sm]} />
|
<MediaInsetBorder opaque style={[a.rounded_sm]} />
|
||||||
</View>
|
</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({
|
export function PublicationFooter({
|
||||||
@@ -578,6 +651,14 @@ export function PublicationFooter({
|
|||||||
a.justify_between,
|
a.justify_between,
|
||||||
a.p_md,
|
a.p_md,
|
||||||
a.gap_md,
|
a.gap_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
// Rounds the bottom corners to match the container border, since the
|
||||||
|
// container itself can't clip (overflow_hidden there breaks the peek
|
||||||
|
// lift animation on the article above).
|
||||||
|
{
|
||||||
|
borderBottomLeftRadius: a.rounded_lg.borderRadius,
|
||||||
|
borderBottomRightRadius: a.rounded_lg.borderRadius,
|
||||||
|
},
|
||||||
gtPhone && [a.flex_row, a.gap_sm],
|
gtPhone && [a.flex_row, a.gap_sm],
|
||||||
interactedOuter && t.atoms.bg_contrast_25,
|
interactedOuter && t.atoms.bg_contrast_25,
|
||||||
preview && a.pointer_events_none,
|
preview && a.pointer_events_none,
|
||||||
@@ -617,6 +698,7 @@ export function PublicationFooter({
|
|||||||
/>
|
/>
|
||||||
<View style={[a.flex_1, a.gap_2xs]}>
|
<View style={[a.flex_1, a.gap_2xs]}>
|
||||||
<Text
|
<Text
|
||||||
|
emoji
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={[
|
style={[
|
||||||
a.text_sm,
|
a.text_sm,
|
||||||
|
|||||||
@@ -108,10 +108,15 @@ export function useActiveVideoWeb() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
active: activeViewId === id,
|
active: activeViewId === id,
|
||||||
setActive: () => {
|
setActive: useCallback(() => {
|
||||||
setActiveView(id)
|
setActiveView(id)
|
||||||
},
|
}, [setActiveView, id]),
|
||||||
currentActiveView: activeViewId,
|
currentActiveView: activeViewId,
|
||||||
sendPosition: (y: number) => sendViewPosition(id, y),
|
sendPosition: useCallback(
|
||||||
|
(y: number) => {
|
||||||
|
sendViewPosition(id, y)
|
||||||
|
},
|
||||||
|
[sendViewPosition, id],
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
|
import {getChatInviteCodeFromUrl} from '#/lib/strings/url-helpers'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
type EmbedType,
|
type EmbedType,
|
||||||
parseEmbed,
|
parseEmbed,
|
||||||
} from '#/types/bsky/post'
|
} from '#/types/bsky/post'
|
||||||
|
import {ChatInviteEmbed} from './ChatInviteEmbed'
|
||||||
import {ExternalEmbed} from './ExternalEmbed'
|
import {ExternalEmbed} from './ExternalEmbed'
|
||||||
import {ModeratedFeedEmbed} from './FeedEmbed'
|
import {ModeratedFeedEmbed} from './FeedEmbed'
|
||||||
import {ImageEmbed} from './ImageEmbed'
|
import {ImageEmbed} from './ImageEmbed'
|
||||||
@@ -52,6 +54,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
|
|||||||
|
|
||||||
switch (embed.type) {
|
switch (embed.type) {
|
||||||
case 'images':
|
case 'images':
|
||||||
|
case 'gallery':
|
||||||
case 'link':
|
case 'link':
|
||||||
case 'video': {
|
case 'video': {
|
||||||
return <MediaEmbed embed={embed} {...rest} />
|
return <MediaEmbed embed={embed} {...rest} />
|
||||||
@@ -87,7 +90,8 @@ function MediaEmbed({
|
|||||||
embed: TEmbed
|
embed: TEmbed
|
||||||
}) {
|
}) {
|
||||||
switch (embed.type) {
|
switch (embed.type) {
|
||||||
case 'images': {
|
case 'images':
|
||||||
|
case 'gallery': {
|
||||||
return (
|
return (
|
||||||
<ContentHider
|
<ContentHider
|
||||||
modui={rest.moderation?.ui('contentMedia')}
|
modui={rest.moderation?.ui('contentMedia')}
|
||||||
@@ -110,6 +114,21 @@ function MediaEmbed({
|
|||||||
</ContentHider>
|
</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 (
|
return (
|
||||||
<ContentHider
|
<ContentHider
|
||||||
modui={rest.moderation?.ui('contentMedia')}
|
modui={rest.moderation?.ui('contentMedia')}
|
||||||
@@ -326,6 +345,9 @@ export function QuoteEmbed({
|
|||||||
allowNestedQuotes={
|
allowNestedQuotes={
|
||||||
parentIsWithinQuote ? false : parentAllowNestedQuotes
|
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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export type CommonProps = {
|
|||||||
viewContext?: PostEmbedViewContext
|
viewContext?: PostEmbedViewContext
|
||||||
isWithinQuote?: boolean
|
isWithinQuote?: boolean
|
||||||
allowNestedQuotes?: 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 & {
|
export type EmbedProps = CommonProps & {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
|||||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
|
import {BlockDialog} from '#/components/moderation/BlockDialog'
|
||||||
import {
|
import {
|
||||||
ReportDialog,
|
ReportDialog,
|
||||||
useReportDialogControl,
|
useReportDialogControl,
|
||||||
@@ -550,9 +551,17 @@ let PostMenuItems = ({
|
|||||||
) : (
|
) : (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownTranslateBtn"
|
testID="postDropdownTranslateBtn"
|
||||||
label={l`Translate`}
|
label={
|
||||||
|
forceGoogleTranslate
|
||||||
|
? l`Open in Google Translate`
|
||||||
|
: l`Translate`
|
||||||
|
}
|
||||||
onPress={onPressTranslate}>
|
onPress={onPressTranslate}>
|
||||||
<Menu.ItemText>{l`Translate`}</Menu.ItemText>
|
<Menu.ItemText>
|
||||||
|
{forceGoogleTranslate
|
||||||
|
? l`Open in Google Translate`
|
||||||
|
: l`Translate`}
|
||||||
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Translate} position="right" />
|
<Menu.ItemIcon icon={Translate} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
@@ -845,13 +854,10 @@ let PostMenuItems = ({
|
|||||||
onConfirm={() => void onToggleReplyVisibility()}
|
onConfirm={() => void onToggleReplyVisibility()}
|
||||||
confirmButtonCta={l`Yes, hide`}
|
confirmButtonCta={l`Yes, hide`}
|
||||||
/>
|
/>
|
||||||
<Prompt.Basic
|
<BlockDialog
|
||||||
control={blockPromptControl}
|
control={blockPromptControl}
|
||||||
title={l`Block Account?`}
|
profile={postAuthor}
|
||||||
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
|
onBlock={onBlockAuthor}
|
||||||
onConfirm={() => void onBlockAuthor()}
|
|
||||||
confirmButtonCta={l`Block`}
|
|
||||||
confirmButtonColor="negative"
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -152,11 +152,7 @@ function RecentChatItem({
|
|||||||
a.align_center,
|
a.align_center,
|
||||||
]}>
|
]}>
|
||||||
{convo.kind === 'group' ? (
|
{convo.kind === 'group' ? (
|
||||||
<AvatarBubbles
|
<AvatarBubbles profiles={convo.members} size={WIDTH - 8} />
|
||||||
profiles={convo.members}
|
|
||||||
size={WIDTH - 8}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatar={primaryProfile.avatar}
|
avatar={primaryProfile.avatar}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {useWindowDimensions, View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
|
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
|
||||||
|
import {useNativeFontScale} from '#/alf/util/dimensions'
|
||||||
import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge'
|
import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge'
|
||||||
import {useSimpleVerificationState} from '#/components/verification'
|
import {useSimpleVerificationState} from '#/components/verification'
|
||||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||||
@@ -31,14 +32,16 @@ export function ProfileBadges({
|
|||||||
interactive = false,
|
interactive = false,
|
||||||
size,
|
size,
|
||||||
style,
|
style,
|
||||||
|
allowFontScaling = true,
|
||||||
}: ViewStyleProp & {
|
}: ViewStyleProp & {
|
||||||
profile: bsky.profile.AnyProfileView
|
profile: bsky.profile.AnyProfileView
|
||||||
interactive?: boolean
|
interactive?: boolean
|
||||||
size: Size
|
size: Size
|
||||||
|
allowFontScaling?: boolean
|
||||||
}) {
|
}) {
|
||||||
const shadowed = useProfileShadow(profile)
|
const shadowed = useProfileShadow(profile)
|
||||||
const verification = useSimpleVerificationState({profile})
|
const verification = useSimpleVerificationState({profile})
|
||||||
const {fontScale: nativeScaleMultiplier} = useWindowDimensions()
|
const nativeScaleMultiplier = useNativeFontScale()
|
||||||
const {
|
const {
|
||||||
fonts: {scaleMultiplier: alfScaleMultiplier},
|
fonts: {scaleMultiplier: alfScaleMultiplier},
|
||||||
} = useAlf()
|
} = useAlf()
|
||||||
@@ -48,10 +51,12 @@ export function ProfileBadges({
|
|||||||
|
|
||||||
const isOnTheSmallSide = size === 'xs' || size === 'sm'
|
const isOnTheSmallSide = size === 'xs' || size === 'sm'
|
||||||
|
|
||||||
const verificationIconWidth =
|
const scaleMultiplier = allowFontScaling
|
||||||
verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
|
? nativeScaleMultiplier * alfScaleMultiplier
|
||||||
const botIconWidth =
|
: 1
|
||||||
botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
|
|
||||||
|
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
|
||||||
|
const botIconWidth = botIconSizes[size] * scaleMultiplier
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {Suspense, useRef, useState} from 'react'
|
import {Suspense, useRef, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import type ViewShot from 'react-native-view-shot'
|
import type ViewShot from 'react-native-view-shot'
|
||||||
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
import {requestPermissionsAsync, saveToLibraryAsync} from 'expo-media-library'
|
||||||
import {createAssetAsync} from 'expo-media-library'
|
|
||||||
import * as Sharing from 'expo-sharing'
|
import * as Sharing from 'expo-sharing'
|
||||||
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
@@ -60,7 +59,9 @@ export function QrCodeDialog({
|
|||||||
const onSavePress = async () => {
|
const onSavePress = async () => {
|
||||||
ref.current?.capture?.().then(async (uri: string) => {
|
ref.current?.capture?.().then(async (uri: string) => {
|
||||||
if (IS_NATIVE) {
|
if (IS_NATIVE) {
|
||||||
const res = await requestMediaLibraryPermissionsAsync()
|
// Write-only permission - saving the QR image does not require read
|
||||||
|
// access to the user's photo library.
|
||||||
|
const res = await requestPermissionsAsync(true)
|
||||||
|
|
||||||
if (!res.granted) {
|
if (!res.granted) {
|
||||||
Toast.show(
|
Toast.show(
|
||||||
@@ -73,7 +74,9 @@ export function QrCodeDialog({
|
|||||||
|
|
||||||
// Incase of a FS failure, don't crash the app
|
// Incase of a FS failure, don't crash the app
|
||||||
try {
|
try {
|
||||||
await createAssetAsync(`file://${uri}`)
|
// saveToLibraryAsync writes without reading the asset back, so it
|
||||||
|
// works with the add-only permission on iOS (APP-2374)
|
||||||
|
await saveToLibraryAsync(`file://${uri}`)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
Toast.show(_(msg`An error occurred while saving the QR code!`), {
|
Toast.show(_(msg`An error occurred while saving the QR code!`), {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function Text({
|
|||||||
title,
|
title,
|
||||||
dataSet,
|
dataSet,
|
||||||
numberOfLines,
|
numberOfLines,
|
||||||
|
allowFontScaling = true,
|
||||||
...rest
|
...rest
|
||||||
}: TextProps) {
|
}: TextProps) {
|
||||||
const {fonts, flags} = useAlf()
|
const {fonts, flags} = useAlf()
|
||||||
@@ -36,7 +37,7 @@ export function Text({
|
|||||||
style,
|
style,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
fontScale: fonts.scaleMultiplier,
|
fontScale: allowFontScaling ? fonts.scaleMultiplier : 1,
|
||||||
fontFamily: fonts.family,
|
fontFamily: fonts.family,
|
||||||
flags,
|
flags,
|
||||||
},
|
},
|
||||||
@@ -57,6 +58,7 @@ export function Text({
|
|||||||
numberOfLines,
|
numberOfLines,
|
||||||
style: s,
|
style: s,
|
||||||
dataSet: Object.assign({tooltip: title}, dataSet || {}),
|
dataSet: Object.assign({tooltip: title}, dataSet || {}),
|
||||||
|
allowFontScaling,
|
||||||
...rest,
|
...rest,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -514,11 +514,7 @@ function ExistingChatCard({
|
|||||||
]}>
|
]}>
|
||||||
<ProfileCard.Header>
|
<ProfileCard.Header>
|
||||||
{convo.kind === 'group' ? (
|
{convo.kind === 'group' ? (
|
||||||
<AvatarBubbles
|
<AvatarBubbles profiles={convo.members} size={40} />
|
||||||
profiles={convo.members}
|
|
||||||
size={40}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<ProfileCard.Avatar
|
<ProfileCard.Avatar
|
||||||
profile={convo.primaryMember}
|
profile={convo.primaryMember}
|
||||||
@@ -557,7 +553,7 @@ function ExistingChatCard({
|
|||||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||||
numberOfLines={2}>
|
numberOfLines={2}>
|
||||||
<Plural
|
<Plural
|
||||||
value={convo.members.length}
|
value={convo.details.memberCount}
|
||||||
one="# member"
|
one="# member"
|
||||||
other="# members"
|
other="# members"
|
||||||
/>
|
/>
|
||||||
@@ -667,7 +663,6 @@ function SearchInput({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
// @ts-ignore bottom sheet input types issue — esb
|
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={l`Search`}
|
placeholder={l`Search`}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||