Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d84a8d5819 |
@@ -1,63 +0,0 @@
|
||||
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.
|
||||
@@ -1,76 +0,0 @@
|
||||
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:*)"
|
||||
@@ -1,68 +0,0 @@
|
||||
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.
|
||||
@@ -0,0 +1,54 @@
|
||||
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
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Codemod to replace BskyAgent with AtpAgent
|
||||
*
|
||||
* Before:
|
||||
* import {BskyAgent} from '@atproto/api`
|
||||
* BskyAgent.appLabelers.includes(labeler)
|
||||
*
|
||||
* After:
|
||||
* import {AtpAgent} from '@atproto/api`
|
||||
* AtpAgent.appLabelers.includes(labeler)
|
||||
*
|
||||
* Handles import specifiers, type annotations, static member access
|
||||
* (BskyAgent.configure), `extends BskyAgent`, and `new BskyAgent()`. Whole
|
||||
* identifiers only, so names like `OpaqueBskyAgent` are left untouched.
|
||||
*
|
||||
* Usage: jscodeshift -t .jscodeshift/repo/bsky-agent.js <file-path>
|
||||
* Example: jscodeshift -t .jscodeshift/repo/bsky-agent.js src/lib/moderation.ts
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
export const parser = 'tsx'
|
||||
|
||||
export default function transformer(file, api) {
|
||||
const j = api.jscodeshift
|
||||
const root = j(file.source)
|
||||
|
||||
// Replace every standalone `BskyAgent` identifier with `AtpAgent`. This
|
||||
// covers imports, type references, member expressions, `extends`, and `new`.
|
||||
root
|
||||
.find(j.Identifier, {name: 'BskyAgent'})
|
||||
.replaceWith(() => j.identifier('AtpAgent'))
|
||||
|
||||
// Renaming can leave a duplicate `AtpAgent` specifier on the @atproto/api
|
||||
// import if the file already imported it. Dedupe by imported name, keeping
|
||||
// the type-only modifier only if every duplicate was type-only.
|
||||
root
|
||||
.find(j.ImportDeclaration, {source: {value: '@atproto/api'}})
|
||||
.forEach(path => {
|
||||
const seen = new Map()
|
||||
for (const spec of path.value.specifiers) {
|
||||
if (spec.type !== 'ImportSpecifier') {
|
||||
seen.set(Symbol(), spec)
|
||||
continue
|
||||
}
|
||||
const name = spec.imported.name
|
||||
const existing = seen.get(name)
|
||||
if (!existing) {
|
||||
seen.set(name, spec)
|
||||
} else if (
|
||||
existing.importKind === 'type' &&
|
||||
spec.importKind !== 'type'
|
||||
) {
|
||||
// Prefer the value (non-type) import if either usage needs it.
|
||||
seen.set(name, spec)
|
||||
}
|
||||
}
|
||||
path.value.specifiers = Array.from(seen.values())
|
||||
})
|
||||
|
||||
return root.toSource()
|
||||
}
|
||||
@@ -9,7 +9,7 @@ WORKDIR /app
|
||||
COPY ./bskyogcard/package.json ./
|
||||
COPY ./bskyogcard/pnpm-lock.yaml ./
|
||||
COPY ./bskyogcard/pnpm-workspace.yaml ./
|
||||
RUN npm install --global pnpm@11.5.2
|
||||
RUN npm install --global pnpm@11.1.3
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ./bskyogcard ./
|
||||
|
||||
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
|
||||
RUN \. "$NVM_DIR/nvm.sh" && \
|
||||
nvm install $NODE_VERSION && \
|
||||
nvm use $NODE_VERSION && \
|
||||
npm install --global pnpm@11.5.3 && \
|
||||
npm install --global pnpm@11.5.0 && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
||||
pnpm intl:build && \
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M5 5a1 1 0 0 0-2 0v4a7 7 0 0 0 7 7h8.086l-2.293 2.293a1 1 0 0 0 1.414 1.414l2.94-2.94a2.5 2.5 0 0 0 0-3.535l-2.94-2.94a1 1 0 1 0-1.414 1.415L18.086 14H10a5 5 0 0 1-5-5V5Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 273 B |
|
Before Width: | Height: | Size: 334 B After Width: | Height: | Size: 334 B |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 54 KiB |
@@ -13,7 +13,7 @@
|
||||
"format": "prettier -w src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.11",
|
||||
"@atproto/api": "^0.15.25",
|
||||
"preact": "^10.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.11
|
||||
version: 0.20.11
|
||||
specifier: ^0.15.25
|
||||
version: 0.15.27
|
||||
preact:
|
||||
specifier: ^10.4.8
|
||||
version: 10.29.1
|
||||
@@ -73,33 +73,32 @@ packages:
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@atproto/api@0.20.11':
|
||||
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
|
||||
engines: {node: '>=22'}
|
||||
'@atproto/api@0.15.27':
|
||||
resolution: {integrity: sha512-ok/WGafh1nz4t8pEQGtAF/32x2E2VDWU4af6BajkO5Gky2jp2q6cv6aB2A5yuvNNcc3XkYMYipsqVHVwLPMF9g==}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
|
||||
engines: {node: '>=22'}
|
||||
'@atproto/common-web@0.4.21':
|
||||
resolution: {integrity: sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==}
|
||||
|
||||
'@atproto/lex-data@0.1.1':
|
||||
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
|
||||
engines: {node: '>=22'}
|
||||
'@atproto/lex-data@0.0.15':
|
||||
resolution: {integrity: sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==}
|
||||
|
||||
'@atproto/lex-json@0.1.0':
|
||||
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
|
||||
engines: {node: '>=22'}
|
||||
'@atproto/lex-json@0.0.16':
|
||||
resolution: {integrity: sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==}
|
||||
|
||||
'@atproto/lexicon@0.7.1':
|
||||
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
|
||||
engines: {node: '>=22'}
|
||||
'@atproto/lexicon@0.4.14':
|
||||
resolution: {integrity: sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==}
|
||||
|
||||
'@atproto/syntax@0.6.1':
|
||||
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
|
||||
engines: {node: '>=22'}
|
||||
'@atproto/lexicon@0.6.2':
|
||||
resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==}
|
||||
|
||||
'@atproto/xrpc@0.8.0':
|
||||
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
|
||||
engines: {node: '>=22'}
|
||||
'@atproto/syntax@0.4.3':
|
||||
resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==}
|
||||
|
||||
'@atproto/syntax@0.5.4':
|
||||
resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==}
|
||||
|
||||
'@atproto/xrpc@0.7.7':
|
||||
resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
@@ -1102,8 +1101,8 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
await-lock@3.0.0:
|
||||
resolution: {integrity: sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==}
|
||||
await-lock@2.2.2:
|
||||
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
|
||||
|
||||
babel-plugin-polyfill-corejs2@0.4.17:
|
||||
resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==}
|
||||
@@ -1788,8 +1787,8 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
multiformats@13.4.2:
|
||||
resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==}
|
||||
multiformats@9.9.0:
|
||||
resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==}
|
||||
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
@@ -2195,8 +2194,8 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uint8arrays@5.1.1:
|
||||
resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==}
|
||||
uint8arrays@3.0.0:
|
||||
resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1:
|
||||
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
|
||||
@@ -2325,51 +2324,63 @@ snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.11':
|
||||
'@atproto/api@0.15.27':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
'@atproto/syntax': 0.6.1
|
||||
'@atproto/xrpc': 0.8.0
|
||||
await-lock: 3.0.0
|
||||
multiformats: 13.4.2
|
||||
'@atproto/common-web': 0.4.21
|
||||
'@atproto/lexicon': 0.4.14
|
||||
'@atproto/syntax': 0.4.3
|
||||
'@atproto/xrpc': 0.7.7
|
||||
await-lock: 2.2.2
|
||||
multiformats: 9.9.0
|
||||
tlds: 1.261.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
'@atproto/common-web@0.4.21':
|
||||
dependencies:
|
||||
'@atproto/lex-data': 0.1.1
|
||||
'@atproto/lex-json': 0.1.0
|
||||
'@atproto/syntax': 0.6.1
|
||||
'@atproto/lex-data': 0.0.15
|
||||
'@atproto/lex-json': 0.0.16
|
||||
'@atproto/syntax': 0.5.4
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/lex-data@0.1.1':
|
||||
'@atproto/lex-data@0.0.15':
|
||||
dependencies:
|
||||
multiformats: 13.4.2
|
||||
multiformats: 9.9.0
|
||||
tslib: 2.8.1
|
||||
uint8arrays: 5.1.1
|
||||
uint8arrays: 3.0.0
|
||||
unicode-segmenter: 0.14.5
|
||||
|
||||
'@atproto/lex-json@0.1.0':
|
||||
'@atproto/lex-json@0.0.16':
|
||||
dependencies:
|
||||
'@atproto/lex-data': 0.1.1
|
||||
'@atproto/lex-data': 0.0.15
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/lexicon@0.7.1':
|
||||
'@atproto/lexicon@0.4.14':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/syntax': 0.6.1
|
||||
multiformats: 13.4.2
|
||||
'@atproto/common-web': 0.4.21
|
||||
'@atproto/syntax': 0.4.3
|
||||
iso-datestring-validator: 2.2.2
|
||||
multiformats: 9.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/syntax@0.6.1':
|
||||
'@atproto/lexicon@0.6.2':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.4.21
|
||||
'@atproto/syntax': 0.5.4
|
||||
iso-datestring-validator: 2.2.2
|
||||
multiformats: 9.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/syntax@0.4.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/xrpc@0.8.0':
|
||||
'@atproto/syntax@0.5.4':
|
||||
dependencies:
|
||||
'@atproto/lexicon': 0.7.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/xrpc@0.7.7':
|
||||
dependencies:
|
||||
'@atproto/lexicon': 0.6.2
|
||||
zod: 3.25.76
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
@@ -3525,7 +3536,7 @@ snapshots:
|
||||
postcss: 8.5.14
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
await-lock@3.0.0: {}
|
||||
await-lock@2.2.2: {}
|
||||
|
||||
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0):
|
||||
dependencies:
|
||||
@@ -4131,7 +4142,7 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
multiformats@13.4.2: {}
|
||||
multiformats@9.9.0: {}
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
@@ -4535,9 +4546,9 @@ snapshots:
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
uint8arrays@5.1.1:
|
||||
uint8arrays@3.0.0:
|
||||
dependencies:
|
||||
multiformats: 13.4.2
|
||||
multiformats: 9.9.0
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedGallery,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
@@ -44,11 +43,6 @@ export function Embed({
|
||||
return <ImageEmbed content={content} labelInfo={labelInfo} />
|
||||
}
|
||||
|
||||
// Case 1b: Gallery (Photos v2)
|
||||
if (AppBskyEmbedGallery.isView(content)) {
|
||||
return <GalleryEmbed content={content} labelInfo={labelInfo} />
|
||||
}
|
||||
|
||||
// Case 2: External link
|
||||
if (AppBskyEmbedExternal.isView(content)) {
|
||||
return <ExternalEmbed content={content} labelInfo={labelInfo} />
|
||||
@@ -236,8 +230,6 @@ function Info({children}: {children: ComponentChildren}) {
|
||||
)
|
||||
}
|
||||
|
||||
type GridImage = {thumb: string; alt: string}
|
||||
|
||||
function ImageEmbed({
|
||||
content,
|
||||
labelInfo,
|
||||
@@ -248,45 +240,20 @@ function ImageEmbed({
|
||||
if (labelInfo) {
|
||||
return <Info>{labelInfo}</Info>
|
||||
}
|
||||
return (
|
||||
<ImageGrid
|
||||
images={content.images.map(i => ({thumb: i.thumb, alt: i.alt}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GalleryEmbed({
|
||||
content,
|
||||
labelInfo,
|
||||
}: {
|
||||
content: AppBskyEmbedGallery.View
|
||||
labelInfo?: string
|
||||
}) {
|
||||
if (labelInfo) {
|
||||
return <Info>{labelInfo}</Info>
|
||||
}
|
||||
const images = content.items
|
||||
.filter(AppBskyEmbedGallery.isViewImage)
|
||||
.map(i => ({thumb: i.thumbnail, alt: i.alt}))
|
||||
return <ImageGrid images={images} />
|
||||
}
|
||||
|
||||
function ImageGrid({images}: {images: GridImage[]}) {
|
||||
switch (images.length) {
|
||||
case 0:
|
||||
return null
|
||||
switch (content.images.length) {
|
||||
case 1:
|
||||
return (
|
||||
<img
|
||||
src={images[0].thumb}
|
||||
alt={images[0].alt}
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]"
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
|
||||
{images.map((image, i) => (
|
||||
{content.images.map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
@@ -301,13 +268,13 @@ function ImageGrid({images}: {images: GridImage[]}) {
|
||||
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
|
||||
<div className="flex-1 aspect-square">
|
||||
<img
|
||||
src={images[0].thumb}
|
||||
alt={images[0].alt}
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
className="w-full h-full object-cover rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
{images.slice(1).map((image, i) => (
|
||||
{content.images.slice(1).map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
@@ -318,36 +285,21 @@ function ImageGrid({images}: {images: GridImage[]}) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
default: {
|
||||
const remaining = images.length - 4
|
||||
case 4:
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
|
||||
{images.slice(0, 4).map((image, i) => {
|
||||
const isOverflowCell = i === 3 && remaining > 0
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="relative aspect-[3/2] rounded-sm overflow-hidden">
|
||||
<img
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{isOverflowCell && (
|
||||
<div
|
||||
aria-label={`+${remaining} more image${remaining === 1 ? '' : 's'}, view post to see all`}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<span className="text-white text-2xl font-semibold">
|
||||
+{remaining}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{content.images.map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="aspect-[3/2] w-full object-cover rounded-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,9 +154,7 @@ export function Post({thread}: Props) {
|
||||
}
|
||||
|
||||
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
// text-only check - posts with no text (e.g. gallery posts) would otherwise
|
||||
// render an empty <p> that adds an extra flex gap above the embed
|
||||
if (!record?.text) return null
|
||||
if (!record) return null
|
||||
|
||||
const rt = new RichText({
|
||||
text: record.text,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.5.2",
|
||||
"version": "11.1.3",
|
||||
"onFail": "warn"
|
||||
}
|
||||
},
|
||||
@@ -18,7 +18,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.13",
|
||||
"@atproto/api": "0.20.6",
|
||||
"@atproto/common": "^0.6.1",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"express": "^4.19.2",
|
||||
|
||||
@@ -7,52 +7,52 @@ importers:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
'@pnpm/exe':
|
||||
specifier: 11.5.2
|
||||
version: 11.5.2
|
||||
specifier: 11.1.3
|
||||
version: 11.1.3
|
||||
pnpm:
|
||||
specifier: 11.5.2
|
||||
version: 11.5.2
|
||||
specifier: 11.1.3
|
||||
version: 11.1.3
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe@11.5.2':
|
||||
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
|
||||
'@pnpm/exe@11.1.3':
|
||||
resolution: {integrity: sha512-J6bSMpZlHVUKuMKtPT/+lrFPpvBiOIgk6HnNC3vmoM/fsMgFhao6ITFwdsUMzdCl2qHf9cnVYA4ZBdsGmVUnpg==}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/linux-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
|
||||
'@pnpm/linux-arm64@11.1.3':
|
||||
resolution: {integrity: sha512-sz3fc0hSqguk2eGe/InelpBD3LP82MzF+8pLzDpYNGuhasGY+VWkuxEo02HczQYRVXTsbpZVepk+Qs2CONc04Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linux-x64@11.5.2':
|
||||
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
|
||||
'@pnpm/linux-x64@11.1.3':
|
||||
resolution: {integrity: sha512-UadJh5fJZWa47OtdZTWLKWDj4z5WpZFB5pS2wOh/kfKypUmQyjmbOMClP7/yJGS2ZtrSjRgYjIEWAjndkWsMaA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
|
||||
'@pnpm/linuxstatic-arm64@11.1.3':
|
||||
resolution: {integrity: sha512-lWmGr96w+VrIRVsEfTXROB3GeQNxrX2Hy32j3USHr6WlqmpH1i7YjavJGpoXeVZbb77fCFjNFAtsJzGXSgy/Og==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.5.2':
|
||||
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
|
||||
'@pnpm/linuxstatic-x64@11.1.3':
|
||||
resolution: {integrity: sha512-I74GDBOPbr5TXgob3ct4hv6BQtLGdsjGJrII2qNl/e2xYHXekjIWE4Eh7RGN4C7xu+BZYKtvnOOr149yR7KxUw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/macos-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
|
||||
'@pnpm/macos-arm64@11.1.3':
|
||||
resolution: {integrity: sha512-nWn155BVa54iNyg4iolVhjMtqumXHPh8ul5CFjQFJXdwgr9MRUgc5EUBML1+7mS8C/7Wcex5HUgn/w3fliHCsQ==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/win-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
|
||||
'@pnpm/win-arm64@11.1.3':
|
||||
resolution: {integrity: sha512-4u6PQL7/WgwdweC2ZJcdCzhM1koRFG5ofcUSrIVsdyo9ePfGq2D9p1rkZbiLEfIGvl3GKOsfMB5DRTgs8es4AQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/win-x64@11.5.2':
|
||||
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
|
||||
'@pnpm/win-x64@11.1.3':
|
||||
resolution: {integrity: sha512-JmhH7ljJ3MWjvWFz/YHbu/27ISCNLZsQb1JG+ib3uHlfUwIJuF6BudXYsbS5Uu0o7xUvC9sPPm+Uho4Or6R3vA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -116,45 +116,45 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pnpm@11.5.2:
|
||||
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
|
||||
pnpm@11.1.3:
|
||||
resolution: {integrity: sha512-yFNX/hfKEt0j3XBxgiZm39fjy3b+IU4zcLXqL7NPKiMRhVCbY+cX880KyzjdP42CvNXoFyQArmeLcOpPvtCJbQ==}
|
||||
engines: {node: '>=22.13'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe@11.5.2':
|
||||
'@pnpm/exe@11.1.3':
|
||||
dependencies:
|
||||
'@reflink/reflink': 0.1.19
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@pnpm/linux-arm64': 11.5.2
|
||||
'@pnpm/linux-x64': 11.5.2
|
||||
'@pnpm/linuxstatic-arm64': 11.5.2
|
||||
'@pnpm/linuxstatic-x64': 11.5.2
|
||||
'@pnpm/macos-arm64': 11.5.2
|
||||
'@pnpm/win-arm64': 11.5.2
|
||||
'@pnpm/win-x64': 11.5.2
|
||||
'@pnpm/linux-arm64': 11.1.3
|
||||
'@pnpm/linux-x64': 11.1.3
|
||||
'@pnpm/linuxstatic-arm64': 11.1.3
|
||||
'@pnpm/linuxstatic-x64': 11.1.3
|
||||
'@pnpm/macos-arm64': 11.1.3
|
||||
'@pnpm/win-arm64': 11.1.3
|
||||
'@pnpm/win-x64': 11.1.3
|
||||
|
||||
'@pnpm/linux-arm64@11.5.2':
|
||||
'@pnpm/linux-arm64@11.1.3':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linux-x64@11.5.2':
|
||||
'@pnpm/linux-x64@11.1.3':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.5.2':
|
||||
'@pnpm/linuxstatic-arm64@11.1.3':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.5.2':
|
||||
'@pnpm/linuxstatic-x64@11.1.3':
|
||||
optional: true
|
||||
|
||||
'@pnpm/macos-arm64@11.5.2':
|
||||
'@pnpm/macos-arm64@11.1.3':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-arm64@11.5.2':
|
||||
'@pnpm/win-arm64@11.1.3':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-x64@11.5.2':
|
||||
'@pnpm/win-x64@11.1.3':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
@@ -194,7 +194,7 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
pnpm@11.5.2: {}
|
||||
pnpm@11.1.3: {}
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
@@ -208,8 +208,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.13
|
||||
version: 0.20.13
|
||||
specifier: 0.20.6
|
||||
version: 0.20.6
|
||||
'@atproto/common':
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1
|
||||
@@ -259,8 +259,8 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@atproto/api@0.20.13':
|
||||
resolution: {integrity: sha512-AN7NTUsygHXnXs5Cnc6v7/BAjQydwAhE0P8hL21RpIAB4fmTksRKTU3TV//wPg4fk9ESsfNwGIAlwoybbyoZKg==}
|
||||
'@atproto/api@0.20.6':
|
||||
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -1154,7 +1154,7 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@atproto/api@0.20.13':
|
||||
'@atproto/api@0.20.6':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {type ChatBskyGroupDefs} from '@atproto/api'
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {type Express} from 'express'
|
||||
import satori from 'satori'
|
||||
@@ -32,7 +32,7 @@ export default function (ctx: AppContext, app: Express) {
|
||||
codes: [code],
|
||||
})
|
||||
const found = result.data.joinLinkPreviews[0]
|
||||
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(found)) {
|
||||
if (!found) {
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
preview = found
|
||||
|
||||
@@ -4,9 +4,10 @@ import (
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
)
|
||||
|
||||
// Helpers for extracting Open Graph metadata from post embeds. og:video
|
||||
// shares findVideoEmbed with the JSON-LD VideoObject path in jsonld.go so
|
||||
// the two outputs cannot drift.
|
||||
// Helpers for extracting Open Graph metadata from post embeds. These feed
|
||||
// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in
|
||||
// jsonld.go. og:video has its own path because JSON-LD does not yet emit
|
||||
// VideoObject (deferred — needs `duration` from the appview).
|
||||
|
||||
// videoMeta holds og:video meta tag data.
|
||||
type videoMeta struct {
|
||||
@@ -20,7 +21,17 @@ type videoMeta struct {
|
||||
// extractVideoMeta returns og:video metadata, or the zero value if there's
|
||||
// no video embed. Respects embedHidden so og:video and og:image stay in sync.
|
||||
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
|
||||
v := findVideoEmbed(pv, embedHidden)
|
||||
if pv == nil || pv.Embed == nil || embedHidden {
|
||||
return videoMeta{}
|
||||
}
|
||||
var v *appbsky.EmbedVideo_View
|
||||
if pv.Embed.EmbedVideo_View != nil {
|
||||
v = pv.Embed.EmbedVideo_View
|
||||
} else if pv.Embed.EmbedRecordWithMedia_View != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
|
||||
v = pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
|
||||
}
|
||||
if v == nil || v.Playlist == "" {
|
||||
return videoMeta{}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ type discussionForumPosting struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Image []string `json:"image,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||
Video *videoObject `json:"video,omitempty"`
|
||||
DatePublished string `json:"datePublished,omitempty"`
|
||||
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
|
||||
CommentCount *int64 `json:"commentCount,omitempty"`
|
||||
@@ -68,20 +67,6 @@ type discussionForumPosting struct {
|
||||
SharedContent *sharedContent `json:"sharedContent,omitempty"`
|
||||
}
|
||||
|
||||
// videoObject is the schema.org VideoObject shape for video embeds.
|
||||
// duration is omitted because the appview does not expose it.
|
||||
type videoObject struct {
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||
UploadDate string `json:"uploadDate,omitempty"`
|
||||
ContentURL string `json:"contentUrl,omitempty"`
|
||||
EmbedURL string `json:"embedUrl,omitempty"`
|
||||
Width int64 `json:"width,omitempty"`
|
||||
Height int64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
// comment is the schema.org Comment shape used in
|
||||
// DiscussionForumPosting.comment[]. The comment property does not accept
|
||||
// DiscussionForumPosting, so replies map to Comment.
|
||||
@@ -93,7 +78,6 @@ type comment struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Image []string `json:"image,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||
Video *videoObject `json:"video,omitempty"`
|
||||
DatePublished string `json:"datePublished,omitempty"`
|
||||
}
|
||||
|
||||
@@ -143,31 +127,6 @@ func bskyPostURLFromATURI(handle, atURI string) string {
|
||||
return bskyPostURL(handle, parsed.RecordKey().String())
|
||||
}
|
||||
|
||||
// bskyPostURLFromATURIWithDIDFallback returns the handle-form post URL
|
||||
// when the handle is usable, otherwise falls back to the DID-form URL
|
||||
// derived from the AT-URI's authority. Returns "" only when the AT-URI is
|
||||
// unparseable or has no record key. Used for nested fields (e.g.
|
||||
// VideoObject.embedUrl) where omitting on handle.invalid would weaken the
|
||||
// emitted structured data.
|
||||
func bskyPostURLFromATURIWithDIDFallback(handle, atURI string) string {
|
||||
parsed, err := syntax.ParseATURI(atURI)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rkey := parsed.RecordKey().String()
|
||||
if rkey == "" {
|
||||
return ""
|
||||
}
|
||||
if url := bskyPostURL(handle, rkey); url != "" {
|
||||
return url
|
||||
}
|
||||
did := parsed.Authority().String()
|
||||
if did == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", did, rkey)
|
||||
}
|
||||
|
||||
// bskyProfileURL returns the canonical handle-form profile URL, or "" if
|
||||
// the handle is unusable.
|
||||
func bskyProfileURL(handle string) string {
|
||||
@@ -177,9 +136,9 @@ func bskyProfileURL(handle string) string {
|
||||
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
|
||||
}
|
||||
|
||||
// extractPostMedia returns thumbnail URLs for the post's image, gallery,
|
||||
// or video embed, byte-identical to what we put in og:image. Callers
|
||||
// derive thumbnailUrl from urls[0].
|
||||
// extractPostMedia returns thumbnail URLs for the post's image or video
|
||||
// embed, byte-identical to what we put in og:image. Callers derive
|
||||
// thumbnailUrl from urls[0].
|
||||
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
|
||||
if pv == nil || pv.Embed == nil || embedHidden {
|
||||
return nil
|
||||
@@ -188,9 +147,6 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
|
||||
if pv.Embed.EmbedImages_View != nil {
|
||||
return imageThumbs(pv.Embed.EmbedImages_View.Images)
|
||||
}
|
||||
if pv.Embed.EmbedGallery_View != nil {
|
||||
return galleryThumbs(pv.Embed.EmbedGallery_View.Items)
|
||||
}
|
||||
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
|
||||
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
@@ -199,9 +155,6 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
|
||||
if media.EmbedImages_View != nil {
|
||||
return imageThumbs(media.EmbedImages_View.Images)
|
||||
}
|
||||
if media.EmbedGallery_View != nil {
|
||||
return galleryThumbs(media.EmbedGallery_View.Items)
|
||||
}
|
||||
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
|
||||
return []string{*media.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
@@ -221,89 +174,6 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
|
||||
return urls
|
||||
}
|
||||
|
||||
// galleryThumbs returns the thumbnail URLs of image items in a gallery
|
||||
// embed, or nil if empty. Items_Elem is a union; non-image variants and
|
||||
// nil entries are skipped so future gallery item types don't break SEO
|
||||
// extraction. Empty Thumbnail strings are also skipped to avoid emitting
|
||||
// <meta property="og:image" content=""> if the appview ever returns one.
|
||||
func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
urls := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil || item.EmbedGallery_ViewImage == nil {
|
||||
continue
|
||||
}
|
||||
if item.EmbedGallery_ViewImage.Thumbnail == "" {
|
||||
continue
|
||||
}
|
||||
urls = append(urls, item.EmbedGallery_ViewImage.Thumbnail)
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
// findVideoEmbed returns the post's video embed view, or nil if there is
|
||||
// none or embeds are hidden. Shared with extractVideoMeta so og:video and
|
||||
// JSON-LD VideoObject stay in sync.
|
||||
func findVideoEmbed(pv *appbsky.FeedDefs_PostView, embedHidden bool) *appbsky.EmbedVideo_View {
|
||||
if pv == nil || pv.Embed == nil || embedHidden {
|
||||
return nil
|
||||
}
|
||||
if pv.Embed.EmbedVideo_View != nil {
|
||||
return pv.Embed.EmbedVideo_View
|
||||
}
|
||||
if pv.Embed.EmbedRecordWithMedia_View != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
|
||||
return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildVideoObject returns a VideoObject for the post's video embed, or
|
||||
// nil if there's no usable video. Falls back to "Video by @<handle>" when
|
||||
// alt text is empty so name is always populated (Google requires it).
|
||||
// description falls back to name for the same reason.
|
||||
func buildVideoObject(pv *appbsky.FeedDefs_PostView, embedURL, postText string, embedHidden bool) *videoObject {
|
||||
v := findVideoEmbed(pv, embedHidden)
|
||||
if v == nil || v.Playlist == "" {
|
||||
return nil
|
||||
}
|
||||
vo := &videoObject{
|
||||
Type: "VideoObject",
|
||||
ContentURL: v.Playlist,
|
||||
EmbedURL: embedURL,
|
||||
// uploadDate uses IndexedAt (not record CreatedAt) for consistency
|
||||
// with DiscussionForumPosting.datePublished on the parent post.
|
||||
UploadDate: pv.IndexedAt,
|
||||
}
|
||||
if v.Thumbnail != nil {
|
||||
vo.ThumbnailURL = *v.Thumbnail
|
||||
}
|
||||
switch {
|
||||
case v.Alt != nil && *v.Alt != "":
|
||||
vo.Name = *v.Alt
|
||||
case pv.Author != nil && pv.Author.Handle != "" && pv.Author.Handle != "handle.invalid":
|
||||
vo.Name = "Video by @" + pv.Author.Handle
|
||||
default:
|
||||
vo.Name = "Video on Bluesky"
|
||||
}
|
||||
if postText != "" {
|
||||
vo.Description = postText
|
||||
} else {
|
||||
vo.Description = vo.Name
|
||||
}
|
||||
if v.AspectRatio != nil {
|
||||
vo.Width = v.AspectRatio.Width
|
||||
vo.Height = v.AspectRatio.Height
|
||||
}
|
||||
return vo
|
||||
}
|
||||
|
||||
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
|
||||
// if the embed is blocked / not-found / detached / a non-post record.
|
||||
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
||||
@@ -493,18 +363,14 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
|
||||
thumb = images[0]
|
||||
}
|
||||
|
||||
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
|
||||
postText := postRecordText(pv)
|
||||
|
||||
node := discussionForumPosting{
|
||||
Type: "DiscussionForumPosting",
|
||||
URL: postURL,
|
||||
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
|
||||
Identifier: pv.Uri,
|
||||
Author: buildAuthor(pv.Author),
|
||||
Text: postText,
|
||||
Text: postRecordText(pv),
|
||||
Image: images,
|
||||
ThumbnailURL: thumb,
|
||||
Video: buildVideoObject(pv, postURL, postText, embedHidden),
|
||||
DatePublished: pv.IndexedAt,
|
||||
InteractionStat: buildPostStats(pv),
|
||||
}
|
||||
@@ -567,17 +433,14 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
|
||||
if len(images) > 0 {
|
||||
thumb = images[0]
|
||||
}
|
||||
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
|
||||
postText := postRecordText(pv)
|
||||
return comment{
|
||||
Type: "Comment",
|
||||
URL: postURL,
|
||||
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
|
||||
Identifier: pv.Uri,
|
||||
Author: buildAuthor(pv.Author),
|
||||
Text: postText,
|
||||
Text: postRecordText(pv),
|
||||
Image: images,
|
||||
ThumbnailURL: thumb,
|
||||
Video: buildVideoObject(pv, postURL, postText, embedHidden),
|
||||
DatePublished: pv.IndexedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,60 +72,6 @@ func withImages(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
|
||||
}
|
||||
}
|
||||
|
||||
// withGallery adds an app.bsky.embed.gallery view with image items.
|
||||
func withGallery(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
var items []*appbsky.EmbedGallery_View_Items_Elem
|
||||
for _, t := range thumbs {
|
||||
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
|
||||
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
|
||||
Thumbnail: t,
|
||||
Fullsize: t + "_full",
|
||||
},
|
||||
})
|
||||
}
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withRecordWithMediaGallery adds a record-with-media embed whose media slot
|
||||
// is an app.bsky.embed.gallery view.
|
||||
func withRecordWithMediaGallery(qHandle, qDid, qRkey string, thumbs ...string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
var items []*appbsky.EmbedGallery_View_Items_Elem
|
||||
for _, t := range thumbs {
|
||||
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
|
||||
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
|
||||
Thumbnail: t,
|
||||
Fullsize: t + "_full",
|
||||
},
|
||||
})
|
||||
}
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
|
||||
Record: &appbsky.EmbedRecord_View{
|
||||
Record: &appbsky.EmbedRecord_View_Record{
|
||||
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
|
||||
Uri: "at://" + qDid + "/app.bsky.feed.post/" + qRkey,
|
||||
Cid: "bafy-quoted",
|
||||
Author: &appbsky.ActorDefs_ProfileViewBasic{
|
||||
Did: qDid,
|
||||
Handle: qHandle,
|
||||
},
|
||||
IndexedAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
Media: &appbsky.EmbedRecordWithMedia_View_Media{
|
||||
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withVideo adds a video embed with a thumbnail.
|
||||
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
@@ -353,88 +299,6 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_WithGallery(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
||||
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
imgs, ok := main["image"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("image should be array, got %T", main["image"])
|
||||
}
|
||||
if len(imgs) != 3 {
|
||||
t.Errorf("expected 3 gallery images, got %d", len(imgs))
|
||||
}
|
||||
if imgs[0] != thumb1 || imgs[1] != thumb2 || imgs[2] != thumb3 {
|
||||
t.Errorf("gallery image[] order wrong: %v", imgs)
|
||||
}
|
||||
if main["thumbnailUrl"] != thumb1 {
|
||||
t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery in the media slot of a record-with-media embed should still
|
||||
// produce og:image / JSON-LD image[]. Quote-post URL still emits
|
||||
// alongside via isBasedOn.
|
||||
func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
|
||||
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
imgs, ok := main["image"].([]any)
|
||||
if !ok || len(imgs) != 1 || imgs[0] != thumb {
|
||||
t.Errorf("expected single gallery thumb in image[], got %v", main["image"])
|
||||
}
|
||||
if main["thumbnailUrl"] != thumb {
|
||||
t.Errorf("thumbnailUrl wrong: %v", main["thumbnailUrl"])
|
||||
}
|
||||
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
|
||||
t.Errorf("isBasedOn should still emit for record-with-media gallery, got %v", main["isBasedOn"])
|
||||
}
|
||||
}
|
||||
|
||||
// Forward-compat: nil items, unknown-variant union elements, and empty
|
||||
// Thumbnail strings must be skipped, not panic or leak as <meta
|
||||
// property="og:image" content="">. Unknown variants are dropped silently
|
||||
// so older deploys keep working when new gallery item types ship.
|
||||
func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery")
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedGallery_View: &appbsky.EmbedGallery_View{
|
||||
Items: []*appbsky.EmbedGallery_View_Items_Elem{
|
||||
nil,
|
||||
{}, // empty union, no variant set
|
||||
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: ""}}, // empty Thumbnail
|
||||
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: thumb}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := extractPostMedia(pv, false)
|
||||
if len(got) != 1 || got[0] != thumb {
|
||||
t.Errorf("expected single thumb, got %v", got)
|
||||
}
|
||||
|
||||
// All-nil / all-unknown gallery should produce no thumbs (not [""]).
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedGallery_View: &appbsky.EmbedGallery_View{
|
||||
Items: []*appbsky.EmbedGallery_View_Items_Elem{nil, {}},
|
||||
},
|
||||
}
|
||||
if got := extractPostMedia(pv, false); got != nil {
|
||||
t.Errorf("expected nil for empty/unknown-only gallery, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
|
||||
@@ -497,25 +361,6 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Symmetric guard for the gallery extraction path. Functionally redundant
|
||||
// with the early-return at the top of extractPostMedia, but exists so the
|
||||
// hide-embed contract is asserted directly against the gallery branch -
|
||||
// catches anyone who later moves the embedHidden check inside an
|
||||
// embed-shape branch.
|
||||
func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/g@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||
withGallery(thumb), withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["image"]; present {
|
||||
t.Errorf("hidden-embed gallery post should not emit image")
|
||||
}
|
||||
if _, present := main["thumbnailUrl"]; present {
|
||||
t.Errorf("hidden-embed gallery post should not emit thumbnailUrl")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
|
||||
// Includes ", \, newline, </script>, and a unicode char.
|
||||
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
||||
@@ -1204,339 +1049,3 @@ func TestBuildProfileJSONLD_HasPartAuthorReviewedBy(t *testing.T) {
|
||||
t.Errorf("verifier identifier wrong: %v", rb[0])
|
||||
}
|
||||
}
|
||||
|
||||
// videoEmbedOpts configures withVideoFull. Zero values mean "not set".
|
||||
type videoEmbedOpts struct {
|
||||
thumbnail string
|
||||
playlist string
|
||||
alt string
|
||||
width int64
|
||||
height int64
|
||||
hasAspect bool
|
||||
recordMedia bool // nest under EmbedRecordWithMedia_View.Media
|
||||
}
|
||||
|
||||
// withVideoFull installs a fully-specified video embed for VideoObject tests.
|
||||
func withVideoFull(o videoEmbedOpts) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
v := &appbsky.EmbedVideo_View{Playlist: o.playlist}
|
||||
if o.thumbnail != "" {
|
||||
v.Thumbnail = strPtr(o.thumbnail)
|
||||
}
|
||||
if o.alt != "" {
|
||||
v.Alt = strPtr(o.alt)
|
||||
}
|
||||
if o.hasAspect {
|
||||
v.AspectRatio = &appbsky.EmbedDefs_AspectRatio{Width: o.width, Height: o.height}
|
||||
}
|
||||
if o.recordMedia {
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
|
||||
Record: &appbsky.EmbedRecord_View{
|
||||
Record: &appbsky.EmbedRecord_View_Record{
|
||||
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
|
||||
Uri: "at://did:plc:quoted/app.bsky.feed.post/q",
|
||||
Cid: "bafy-quoted",
|
||||
Author: &appbsky.ActorDefs_ProfileViewBasic{
|
||||
Did: "did:plc:quoted",
|
||||
Handle: "quoted.bsky.social",
|
||||
},
|
||||
IndexedAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
Media: &appbsky.EmbedRecordWithMedia_View_Media{
|
||||
EmbedVideo_View: v,
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedVideo_View: v,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
playlist := "https://video.bsky.app/v/did:plc:alice/v/playlist.m3u8"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch this",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
thumbnail: thumb, playlist: playlist, alt: "A trip to the park",
|
||||
hasAspect: true, width: 16, height: 9,
|
||||
}))
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("video missing from mainEntity")
|
||||
}
|
||||
if video["@type"] != "VideoObject" {
|
||||
t.Errorf("@type = %v, want VideoObject", video["@type"])
|
||||
}
|
||||
if video["name"] != "A trip to the park" {
|
||||
t.Errorf("name = %v, want alt text", video["name"])
|
||||
}
|
||||
if video["description"] != "watch this" {
|
||||
t.Errorf("description = %v, want post text", video["description"])
|
||||
}
|
||||
if video["thumbnailUrl"] != thumb {
|
||||
t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb)
|
||||
}
|
||||
if video["uploadDate"] != pv.IndexedAt {
|
||||
t.Errorf("uploadDate = %v, want %v", video["uploadDate"], pv.IndexedAt)
|
||||
}
|
||||
if video["contentUrl"] != playlist {
|
||||
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
|
||||
}
|
||||
if video["embedUrl"] != canonical {
|
||||
t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical)
|
||||
}
|
||||
if w, _ := video["width"].(float64); int64(w) != 16 {
|
||||
t.Errorf("width = %v, want 16", video["width"])
|
||||
}
|
||||
if h, _ := video["height"].(float64); int64(h) != 9 {
|
||||
t.Errorf("height = %v, want 9", video["height"])
|
||||
}
|
||||
// post thumbnailUrl should equal the video thumb (byte-equal og:image).
|
||||
if main["thumbnailUrl"] != thumb {
|
||||
t.Errorf("post thumbnailUrl = %v, want %v", main["thumbnailUrl"], thumb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
|
||||
// No alt text -> "Video by @<handle>".
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no alt here",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("video missing")
|
||||
}
|
||||
if video["name"] != "Video by @alice.bsky.social" {
|
||||
t.Errorf("name fallback = %v", video["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
|
||||
// handle.invalid + no alt -> generic fallback.
|
||||
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "x",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video := main["video"].(map[string]any)
|
||||
if video["name"] != "Video on Bluesky" {
|
||||
t.Errorf("name fallback = %v, want generic", video["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
|
||||
// Empty post text -> description falls back to name so Google's video
|
||||
// rich-result requirement (description present) is satisfied.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video := main["video"].(map[string]any)
|
||||
if video["description"] != "scenic clip" {
|
||||
t.Errorf("description fallback = %v, want name", video["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video := main["video"].(map[string]any)
|
||||
if _, present := video["width"]; present {
|
||||
t.Errorf("width should be omitted when AspectRatio missing")
|
||||
}
|
||||
if _, present := video["height"]; present {
|
||||
t.Errorf("height should be omitted when AspectRatio missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
|
||||
// No playlist -> VideoObject suppressed; image[]/thumbnailUrl still set
|
||||
// from the video thumb (existing behavior).
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
|
||||
withVideo(thumb))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
t.Errorf("video without playlist should not produce VideoObject")
|
||||
}
|
||||
if main["thumbnailUrl"] != thumb {
|
||||
t.Errorf("thumbnailUrl should still be set from video thumb, got %v", main["thumbnailUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
|
||||
// Self-labeled hide drops the video, parallel to the image-hide test.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
thumbnail: "https://cdn.bsky.app/img/x@jpeg",
|
||||
playlist: "https://video.bsky.app/p.m3u8",
|
||||
alt: "should be dropped",
|
||||
}),
|
||||
withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
t.Errorf("hidden-embed post should not emit video")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
playlist := "https://video.bsky.app/p.m3u8"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+video",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
|
||||
hasAspect: true, width: 4, height: 3,
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("video missing on record-with-media post")
|
||||
}
|
||||
if video["contentUrl"] != playlist {
|
||||
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
|
||||
}
|
||||
if video["thumbnailUrl"] != thumb {
|
||||
t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb)
|
||||
}
|
||||
// quote-post still surfaces via isBasedOn alongside the video.
|
||||
if main["isBasedOn"] != "https://bsky.app/profile/quoted.bsky.social/post/q" {
|
||||
t.Errorf("isBasedOn = %v", main["isBasedOn"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
*pv.ReplyCount = 1
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:bob/v@jpeg"
|
||||
playlist := "https://video.bsky.app/bob.m3u8"
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "watch",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
video, ok := c["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("reply video missing")
|
||||
}
|
||||
if video["@type"] != "VideoObject" {
|
||||
t.Errorf("reply video @type = %v", video["@type"])
|
||||
}
|
||||
if video["name"] != "bob's clip" {
|
||||
t.Errorf("reply video name = %v", video["name"])
|
||||
}
|
||||
if video["contentUrl"] != playlist {
|
||||
t.Errorf("reply video contentUrl = %v", video["contentUrl"])
|
||||
}
|
||||
if video["embedUrl"] != "https://bsky.app/profile/bob.bsky.social/post/rep1" {
|
||||
t.Errorf("reply video embedUrl = %v", video["embedUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
t.Errorf("post without video should not include video field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileJSONLD_HasPartVideo(t *testing.T) {
|
||||
pv := newProfileViewDetailed()
|
||||
playlist := "https://video.bsky.app/p.m3u8"
|
||||
post := makePostView("alice.bsky.social", "did:plc:alice", "rp1", "see",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: playlist, alt: "alt",
|
||||
}))
|
||||
out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{post}, hideEmbedLabels, hideReplyLabels)
|
||||
page := unmarshalLD(t, out)
|
||||
hp := page["hasPart"].([]any)
|
||||
if len(hp) != 1 {
|
||||
t.Fatalf("expected 1 hasPart entry, got %d", len(hp))
|
||||
}
|
||||
video, ok := hp[0].(map[string]any)["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("hasPart entry should carry video, got %v", hp[0])
|
||||
}
|
||||
if video["contentUrl"] != playlist {
|
||||
t.Errorf("hasPart video contentUrl = %v", video["contentUrl"])
|
||||
}
|
||||
if video["embedUrl"] != "https://bsky.app/profile/alice.bsky.social/post/rp1" {
|
||||
t.Errorf("hasPart video embedUrl = %v", video["embedUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
// handle.invalid authors must still produce a non-empty embedUrl on the
|
||||
// VideoObject. The handle-form URL is unusable, so we fall back to the
|
||||
// DID-form URL derived from the AT-URI authority. Without this fallback,
|
||||
// omitempty drops embedUrl and Google's video indexer loses the canonical
|
||||
// page reference.
|
||||
func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
|
||||
playlist := "https://video.bsky.app/p.m3u8"
|
||||
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "watch",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: playlist, alt: "scenic clip",
|
||||
}))
|
||||
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
||||
out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("video missing on handle.invalid post")
|
||||
}
|
||||
if video["embedUrl"] != canonical {
|
||||
t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical)
|
||||
}
|
||||
if video["contentUrl"] != playlist {
|
||||
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
|
||||
}
|
||||
}
|
||||
|
||||
// Same fallback applies to videos on replies whose author is handle.invalid.
|
||||
func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
*pv.ReplyCount = 1
|
||||
playlist := "https://video.bsky.app/bob.m3u8"
|
||||
reply := makePostView("handle.invalid", "did:plc:bob", "rep1", "watch",
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: playlist, alt: "bob's clip",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
video, ok := c["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("reply video missing")
|
||||
}
|
||||
want := "https://bsky.app/profile/did:plc:bob/post/rep1"
|
||||
if video["embedUrl"] != want {
|
||||
t.Errorf("reply video embedUrl = %v, want %v", video["embedUrl"], want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +59,6 @@ func run(args []string) {
|
||||
Value: ":8100",
|
||||
EnvVars: []string{"HTTP_ADDRESS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "metrics-address",
|
||||
Usage: "Specify the local IP/port to bind the metrics server to",
|
||||
Required: false,
|
||||
Value: ":9090",
|
||||
EnvVars: []string{"METRICS_HTTP_ADDRESS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "link-host",
|
||||
Usage: "scheme, hostname, and port of link service",
|
||||
|
||||
@@ -43,13 +43,6 @@ func extractJSONLD(t *testing.T, html string) string {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func TestRenderBase_NoindexMeta(t *testing.T) {
|
||||
html := renderTemplate(t, "base.html", pongo2.Context{"noindex": true, "nofollow": true})
|
||||
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
|
||||
t.Errorf("expected combined noindex,nofollow meta; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPost_EmitsJSONLD(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
@@ -105,40 +98,6 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery posts must hit the same og:image / JSON-LD image[] byte-equality
|
||||
// contract that legacy images posts do. Regression guard for the
|
||||
// app.bsky.embed.gallery extraction path.
|
||||
func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
|
||||
thumbs := extractPostMedia(pv, false)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"postJSONLD": ld,
|
||||
"imgThumbUrls": thumbs,
|
||||
})
|
||||
|
||||
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
|
||||
t.Errorf("og:image[0] not found in rendered HTML for gallery post")
|
||||
}
|
||||
if !strings.Contains(html, `<meta property="og:image" content="`+thumb2+`">`) {
|
||||
t.Errorf("og:image[1] not found in rendered HTML for gallery post")
|
||||
}
|
||||
body := extractJSONLD(t, html)
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal([]byte(body), &parsed)
|
||||
main := parsed["mainEntity"].(map[string]any)
|
||||
imgs := main["image"].([]any)
|
||||
if len(imgs) != 2 || imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
|
||||
t.Errorf("JSON-LD image strings drifted from og:image for gallery; image=%v thumbnailUrl=%v",
|
||||
imgs, main["thumbnailUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
||||
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
@@ -250,50 +209,3 @@ func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
|
||||
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth-required posts must emit noindex,nofollow so the stub page (no body
|
||||
// text, no comments) is not indexed.
|
||||
func TestRenderPost_AuthRequiredNoindex(t *testing.T) {
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"requiresAuth": true,
|
||||
"profileHandle": "alice.bsky.social",
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"noindex": true,
|
||||
"nofollow": true,
|
||||
})
|
||||
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
|
||||
t.Errorf("auth-required post should emit noindex,nofollow; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth-required profiles must emit noindex,nofollow.
|
||||
func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
|
||||
pv := newProfileViewDetailed()
|
||||
html := renderTemplate(t, "profile.html", pongo2.Context{
|
||||
"profileView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social",
|
||||
"requiresAuth": true,
|
||||
"noindex": true,
|
||||
"nofollow": true,
|
||||
})
|
||||
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
|
||||
t.Errorf("auth-required profile should emit noindex,nofollow; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// Public posts must NOT emit a robots meta tag. Guards against an accidental
|
||||
// flip of the noindex flag for indexable pages.
|
||||
func TestRenderPost_PublicNoNoindex(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
"postJSONLD": ld,
|
||||
})
|
||||
if strings.Contains(html, `<meta name="robots"`) {
|
||||
t.Errorf("public post should not emit robots meta; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
@@ -36,17 +35,15 @@ import (
|
||||
"github.com/labstack/echo-contrib/echoprometheus"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
metricsHttpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
chatXrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
chatXrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
|
||||
ipccClient http.Client
|
||||
|
||||
@@ -70,7 +67,6 @@ type Config struct {
|
||||
func serve(cctx *cli.Context) error {
|
||||
debug := cctx.Bool("debug")
|
||||
httpAddress := cctx.String("http-address")
|
||||
metricsAddress := cctx.String("metrics-address")
|
||||
appviewHost := cctx.String("appview-host")
|
||||
chatHost := cctx.String("chat-host")
|
||||
ogcardHost := cctx.String("ogcard-host")
|
||||
@@ -300,50 +296,50 @@ func serve(cctx *cli.Context) error {
|
||||
// generic routes
|
||||
e.GET("/hashtag/:tag", server.WebGeneric)
|
||||
e.GET("/topic/:topic", server.WebGeneric)
|
||||
e.GET("/search", server.WebGenericNoindex)
|
||||
e.GET("/feeds", server.WebGenericNoindex)
|
||||
e.GET("/notifications", server.WebGenericNoindex)
|
||||
e.GET("/notifications/settings", server.WebGenericNoindex)
|
||||
e.GET("/notifications/activity", server.WebGenericNoindex)
|
||||
e.GET("/lists", server.WebGenericNoindex)
|
||||
e.GET("/moderation", server.WebGenericNoindex)
|
||||
e.GET("/moderation/modlists", server.WebGenericNoindex)
|
||||
e.GET("/moderation/muted-accounts", server.WebGenericNoindex)
|
||||
e.GET("/moderation/blocked-accounts", server.WebGenericNoindex)
|
||||
e.GET("/moderation/verification-settings", server.WebGenericNoindex)
|
||||
e.GET("/settings", server.WebGenericNoindex)
|
||||
e.GET("/settings/language", server.WebGenericNoindex)
|
||||
e.GET("/settings/app-passwords", server.WebGenericNoindex)
|
||||
e.GET("/settings/following-feed", server.WebGenericNoindex)
|
||||
e.GET("/settings/saved-feeds", server.WebGenericNoindex)
|
||||
e.GET("/settings/threads", server.WebGenericNoindex)
|
||||
e.GET("/settings/external-embeds", server.WebGenericNoindex)
|
||||
e.GET("/settings/accessibility", server.WebGenericNoindex)
|
||||
e.GET("/settings/appearance", server.WebGenericNoindex)
|
||||
e.GET("/settings/account", server.WebGenericNoindex)
|
||||
e.GET("/settings/automation-label", server.WebGenericNoindex)
|
||||
e.GET("/settings/privacy-and-security", server.WebGenericNoindex)
|
||||
e.GET("/settings/privacy-and-security/activity", server.WebGenericNoindex)
|
||||
e.GET("/settings/content-and-media", server.WebGenericNoindex)
|
||||
e.GET("/settings/interests", server.WebGenericNoindex)
|
||||
e.GET("/settings/about", server.WebGenericNoindex)
|
||||
e.GET("/settings/notifications", server.WebGenericNoindex)
|
||||
e.GET("/sys/debug", server.WebGenericNoindex)
|
||||
e.GET("/sys/debug-mod", server.WebGenericNoindex)
|
||||
e.GET("/sys/log", server.WebGenericNoindex)
|
||||
e.GET("/search", server.WebGeneric)
|
||||
e.GET("/feeds", server.WebGeneric)
|
||||
e.GET("/notifications", server.WebGeneric)
|
||||
e.GET("/notifications/settings", server.WebGeneric)
|
||||
e.GET("/notifications/activity", server.WebGeneric)
|
||||
e.GET("/lists", server.WebGeneric)
|
||||
e.GET("/moderation", server.WebGeneric)
|
||||
e.GET("/moderation/modlists", server.WebGeneric)
|
||||
e.GET("/moderation/muted-accounts", server.WebGeneric)
|
||||
e.GET("/moderation/blocked-accounts", server.WebGeneric)
|
||||
e.GET("/moderation/verification-settings", server.WebGeneric)
|
||||
e.GET("/settings", server.WebGeneric)
|
||||
e.GET("/settings/language", server.WebGeneric)
|
||||
e.GET("/settings/app-passwords", server.WebGeneric)
|
||||
e.GET("/settings/following-feed", server.WebGeneric)
|
||||
e.GET("/settings/saved-feeds", server.WebGeneric)
|
||||
e.GET("/settings/threads", server.WebGeneric)
|
||||
e.GET("/settings/external-embeds", server.WebGeneric)
|
||||
e.GET("/settings/accessibility", server.WebGeneric)
|
||||
e.GET("/settings/appearance", server.WebGeneric)
|
||||
e.GET("/settings/account", server.WebGeneric)
|
||||
e.GET("/settings/automation-label", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
|
||||
e.GET("/settings/content-and-media", server.WebGeneric)
|
||||
e.GET("/settings/interests", server.WebGeneric)
|
||||
e.GET("/settings/about", server.WebGeneric)
|
||||
e.GET("/settings/notifications", server.WebGeneric)
|
||||
e.GET("/sys/debug", server.WebGeneric)
|
||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||
e.GET("/sys/log", server.WebGeneric)
|
||||
e.GET("/support", server.WebGeneric)
|
||||
e.GET("/support/privacy", server.WebGeneric)
|
||||
e.GET("/support/tos", server.WebGeneric)
|
||||
e.GET("/support/community-guidelines", server.WebGeneric)
|
||||
e.GET("/support/copyright", server.WebGeneric)
|
||||
e.GET("/intent/compose", server.WebGenericNoindexNofollow)
|
||||
e.GET("/intent/verify-email", server.WebGenericNoindexNofollow)
|
||||
e.GET("/intent/age-assurance", server.WebGenericNoindexNofollow)
|
||||
e.GET("/messages", server.WebGenericNoindex)
|
||||
e.GET("/messages/inbox", server.WebGenericNoindex)
|
||||
e.GET("/messages/:conversation", server.WebGenericNoindex)
|
||||
e.GET("/messages/:conversation/settings", server.WebGenericNoindex)
|
||||
e.GET("/messages/:conversation/requests", server.WebGenericNoindex)
|
||||
e.GET("/intent/compose", server.WebGeneric)
|
||||
e.GET("/intent/verify-email", server.WebGeneric)
|
||||
e.GET("/intent/age-assurance", server.WebGeneric)
|
||||
e.GET("/messages", server.WebGeneric)
|
||||
e.GET("/messages/inbox", server.WebGeneric)
|
||||
e.GET("/messages/:conversation", server.WebGeneric)
|
||||
e.GET("/messages/:conversation/settings", server.WebGeneric)
|
||||
e.GET("/messages/:conversation/requests", server.WebGeneric)
|
||||
|
||||
// profile endpoints; only first populates info
|
||||
e.GET("/profile/:handleOrDID", server.WebProfile)
|
||||
@@ -367,14 +363,14 @@ func serve(cctx *cli.Context) error {
|
||||
|
||||
// starter packs
|
||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
e.GET("/starter-pack-short/:code", server.WebGenericNoindex)
|
||||
e.GET("/starter-pack-short/:code", server.WebGeneric)
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
|
||||
// chat invites
|
||||
e.GET("/chat/:code", server.WebChatInvite)
|
||||
|
||||
// bookmarks
|
||||
e.GET("/saved", server.WebGenericNoindex)
|
||||
e.GET("/saved", server.WebGeneric)
|
||||
|
||||
// ipcc
|
||||
e.GET("/ipcc", server.WebIpCC)
|
||||
@@ -391,19 +387,6 @@ func serve(cctx *cli.Context) error {
|
||||
e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl))
|
||||
}
|
||||
|
||||
metricsHttpd, metricsListener, err := newMetricsHTTPServer(metricsAddress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server.metricsHttpd = metricsHttpd
|
||||
|
||||
log.Infof("starting metrics server address=%s", metricsAddress)
|
||||
go func() {
|
||||
if err := metricsHttpd.Serve(metricsListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Errorf("metrics HTTP server shutting down unexpectedly: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start the server.
|
||||
log.Infof("starting server address=%s", httpAddress)
|
||||
go func() {
|
||||
@@ -436,24 +419,6 @@ func serve(cctx *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newMetricsHTTPServer(address string) (*http.Server, net.Listener, error) {
|
||||
metricsMux := http.NewServeMux()
|
||||
metricsMux.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
metricsHttpd := &http.Server{
|
||||
Addr: address,
|
||||
Handler: metricsMux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
metricsListener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("listen metrics address %s: %w", address, err)
|
||||
}
|
||||
|
||||
return metricsHttpd, metricsListener, nil
|
||||
}
|
||||
|
||||
func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
srv.echo.ServeHTTP(rw, req)
|
||||
}
|
||||
@@ -464,18 +429,7 @@ func (srv *Server) Shutdown() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var shutdownErr error
|
||||
if srv.metricsHttpd != nil {
|
||||
if err := srv.metricsHttpd.Shutdown(ctx); err != nil {
|
||||
shutdownErr = fmt.Errorf("metrics HTTP server shutdown error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := srv.httpd.Shutdown(ctx); err != nil {
|
||||
return errors.Join(shutdownErr, err)
|
||||
}
|
||||
|
||||
return shutdownErr
|
||||
return srv.httpd.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// NewTemplateContext returns a new pongo2 context with some default values.
|
||||
@@ -483,8 +437,6 @@ func (srv *Server) NewTemplateContext() pongo2.Context {
|
||||
return pongo2.Context{
|
||||
"staticCDNHost": srv.cfg.staticCDNHost,
|
||||
"favicon": fmt.Sprintf("%s/static/favicon.png", srv.cfg.staticCDNHost),
|
||||
"noindex": false,
|
||||
"nofollow": false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,38 +489,10 @@ func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
|
||||
)
|
||||
}
|
||||
|
||||
// renderOptions controls per-request rendering flags for the generic web handler.
|
||||
type renderOptions struct {
|
||||
noindex bool
|
||||
nofollow bool
|
||||
}
|
||||
|
||||
// webGeneric returns a handler that renders the base SPA shell with the given
|
||||
// render options applied to the template context.
|
||||
func (srv *Server) webGeneric(c echo.Context, o renderOptions) error {
|
||||
data := srv.NewTemplateContext()
|
||||
data["noindex"] = o.noindex
|
||||
data["nofollow"] = o.nofollow
|
||||
return c.Render(http.StatusOK, "base.html", data)
|
||||
}
|
||||
|
||||
// handler for endpoint that have no specific server-side handling
|
||||
func (srv *Server) WebGeneric(c echo.Context) error {
|
||||
return srv.webGeneric(c, renderOptions{})
|
||||
}
|
||||
|
||||
// handler for routes that should not be indexed by search engines
|
||||
// (e.g. auth-only user-state surfaces, internal/debug pages, action/intent dispatch URLs, search results)
|
||||
func (srv *Server) WebGenericNoindex(c echo.Context) error {
|
||||
return srv.webGeneric(c, renderOptions{noindex: true})
|
||||
}
|
||||
|
||||
// handler for action/intent dispatch URLs (e.g. /intent/compose). These accept
|
||||
// arbitrary query parameters from arbitrary third-party referrers, so we treat
|
||||
// them as link-graph dead-ends in addition to noindex. Anything legitimately
|
||||
// reachable from a hydrated intent page is also reachable via its canonical URL.
|
||||
func (srv *Server) WebGenericNoindexNofollow(c echo.Context) error {
|
||||
return srv.webGeneric(c, renderOptions{noindex: true, nofollow: true})
|
||||
data := srv.NewTemplateContext()
|
||||
return c.Render(http.StatusOK, "base.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebHome(c echo.Context) error {
|
||||
@@ -643,8 +567,6 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
data["canonicalURL"] = canonicalURL
|
||||
}
|
||||
data["requiresAuth"] = true
|
||||
data["noindex"] = true
|
||||
data["nofollow"] = true
|
||||
data["profileHandle"] = pv.Handle
|
||||
if pv.DisplayName != nil {
|
||||
data["profileDisplayName"] = *pv.DisplayName
|
||||
@@ -751,7 +673,6 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
|
||||
req := c.Request()
|
||||
ctx := req.Context()
|
||||
data := srv.NewTemplateContext()
|
||||
data["noindex"] = true
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
|
||||
code := c.Param("code")
|
||||
@@ -839,8 +760,6 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
||||
}
|
||||
} else {
|
||||
data["requiresAuth"] = true
|
||||
data["noindex"] = true
|
||||
data["nofollow"] = true
|
||||
}
|
||||
|
||||
if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c
|
||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0
|
||||
github.com/flosch/pongo2/v6 v6.0.0
|
||||
github.com/ipfs/go-log v1.0.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
||||
@@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
|
||||
@@ -139,7 +139,6 @@
|
||||
<meta name="theme-color">
|
||||
<meta name="application-name" content="Bluesky">
|
||||
<meta name="generator" content="bskyweb">
|
||||
{% if noindex %}<meta name="robots" content="noindex{% if nofollow %}, nofollow{% endif %}">{% endif %}
|
||||
<meta property="og:site_name" content="Bluesky Social">
|
||||
<meta property="og:logo" content="{{ favicon }}">
|
||||
<meta name="twitter:site" content="@bluesky" />
|
||||
|
||||
@@ -57,9 +57,6 @@ release or OTA.
|
||||
Log in to the EAS CLI with `eas login` and manually set the build numbers to the
|
||||
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
|
||||
> npx eas build:version:set -p ios
|
||||
>
|
||||
@@ -117,7 +114,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:
|
||||
- Launch the app (or quit and reopen) and wait ~15s for the download to complete
|
||||
- Quit and reopen the app
|
||||
- Check the `Settings > About` page and confirm the hash matches the most recent hash on your OTA branch
|
||||
- Check the `Settings > About` page and confirm the has matches the most recent has on your OTA branch
|
||||
|
||||
### 9. Reset build numbers
|
||||
|
||||
|
||||
@@ -1,113 +1,4 @@
|
||||
{
|
||||
"modules/bottom-sheet/src/BottomSheetNativeComponent.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"modules/bottom-sheet/src/BottomSheetPortal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/bottom-sheet/src/lib/Portal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts": {
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-gif-view/src/GifView.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 4
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 4
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-gif-view/src/GifView.web.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts": {
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts": {
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/SharedPrefs/index.native.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 9
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx": {
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx": {
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-emoji-picker/src/EmojiPickerView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/Navigation.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -128,6 +19,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/ageAssurance/util.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/alf/util/flatten.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -617,6 +513,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/InAppBrowserConsent.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/LanguageSelectDialog.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -624,7 +525,7 @@
|
||||
},
|
||||
"src/components/dialogs/LinkWarning.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/dialogs/MutedWords.tsx": {
|
||||
@@ -702,6 +603,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dms/MessageItem.tsx": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/forms/DateField/index.web.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -744,6 +650,14 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/hooks/useFullscreen.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/hooks/useLandingEntry.native.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
@@ -843,6 +757,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/features/liveNow/components/LiveStatusDialog.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/features/liveNow/index.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
@@ -965,6 +884,14 @@
|
||||
"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": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
@@ -1976,6 +1903,12 @@
|
||||
"src/state/session/agent.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/shell/color-mode.tsx": {
|
||||
@@ -2338,6 +2271,23 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/profile/ProfileMenu.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 6
|
||||
},
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 4
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 6
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 12
|
||||
}
|
||||
},
|
||||
"src/view/com/testing/TestCtrls.e2e.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -2376,6 +2326,9 @@
|
||||
"src/view/com/util/Link.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/util/LoadingPlaceholder.tsx": {
|
||||
|
||||
@@ -67,18 +67,7 @@ export class GifView extends PureComponent<GifViewProps> {
|
||||
}
|
||||
|
||||
async playAsync(): Promise<void> {
|
||||
try {
|
||||
await this.videoPlayerRef.current?.play()
|
||||
} catch (err) {
|
||||
// `play()` rejects with a NotAllowedError when the browser blocks
|
||||
// playback (e.g. Safari low-power mode or autoplay policy). This is
|
||||
// expected and benign - the GIF simply stays paused - so swallow it
|
||||
// rather than letting it surface as an unhandled rejection.
|
||||
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
this.videoPlayerRef.current?.play()
|
||||
}
|
||||
|
||||
async pauseAsync(): Promise<void> {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import {type StyleProp, type ViewStyle} from 'react-native'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {type VisibilityViewProps} from './types'
|
||||
import {VisibilityViewProps} from './types'
|
||||
const NativeView: React.ComponentType<{
|
||||
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
|
||||
children: React.ReactNode
|
||||
enabled: boolean
|
||||
enabled: Boolean
|
||||
style: StyleProp<ViewStyle>
|
||||
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.125.0",
|
||||
"version": "1.123.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
@@ -8,7 +8,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.5.3",
|
||||
"version": "11.5.0",
|
||||
"onFail": "warn"
|
||||
},
|
||||
"runtime": {
|
||||
@@ -59,7 +59,7 @@
|
||||
"test-watch": "NODE_ENV=test jest --watchAll",
|
||||
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"test-coverage": "NODE_ENV=test jest --coverage",
|
||||
"lint": "eslint --cache --quiet src modules",
|
||||
"lint": "eslint --cache --quiet src",
|
||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||
"typecheck": "tsgo --project ./tsconfig.check.json",
|
||||
@@ -93,7 +93,7 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.15",
|
||||
"@atproto/api": "0.20.9",
|
||||
"@atproto/syntax": "0.6.1",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
@@ -103,7 +103,7 @@
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
"@bsky.app/peek-menu": "^0.3.0",
|
||||
"@bsky.app/peek-menu": "^0.2.4",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@bsky.app/sift": "^0.3.8",
|
||||
"@bsky.app/tapper": "^0.5.7",
|
||||
|
||||
@@ -7,52 +7,52 @@ importers:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
'@pnpm/exe':
|
||||
specifier: 11.5.3
|
||||
version: 11.5.3
|
||||
specifier: 11.5.0
|
||||
version: 11.5.0
|
||||
pnpm:
|
||||
specifier: 11.5.3
|
||||
version: 11.5.3
|
||||
specifier: 11.5.0
|
||||
version: 11.5.0
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe@11.5.3':
|
||||
resolution: {integrity: sha512-eb8921VsrlLhIf6z/6lopFZ25MH60qgybvPBMHH17s8AAcfh5ipBAHBpxZ3PKNNpJ7v8aZZdZkccDBjwfsUjsA==}
|
||||
'@pnpm/exe@11.5.0':
|
||||
resolution: {integrity: sha512-4hzOXq1HHrNPjwI8k1rt7Ot/Yrdx1JX3pn/L/M95ii1gid1Q6ZK6dVg4+gbSgUdPsYmYDZ4/Yfc0A7vd5C0ndg==}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/linux-arm64@11.5.3':
|
||||
resolution: {integrity: sha512-3UebCq+4osz5/p6GNfH8iEepCUchGDS778ShcIHkJioCUV/QnmLI3IT0MYOymwc+p5/+ECZlUYEfON2FH2seOg==}
|
||||
'@pnpm/linux-arm64@11.5.0':
|
||||
resolution: {integrity: sha512-NV9HdzzCB0epuI9LqZZeTaqjH3OweNQSQCS76GzEkFxJHS9e5Gvu7tgex91gxVL7bCZ+R4yr/3d3yexBFtr2ug==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linux-x64@11.5.3':
|
||||
resolution: {integrity: sha512-fqAhjHjTcu16uryQb1JSbvGThaYp32vuu52teL4FhBaXC1fsgcqWoOEQgFnGfJKXAQO8jEKDb/1BiScQFV+O9g==}
|
||||
'@pnpm/linux-x64@11.5.0':
|
||||
resolution: {integrity: sha512-vH83rRx4iPk/bwm9pBVCn+5hXbcQI66I/4zk6Vc09SusJgTqOdbN4U6VhMcGIqSEdr901ksYGCyIbMv7f6Guew==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.5.3':
|
||||
resolution: {integrity: sha512-z+bw8dIFhFL4Ou82Emjmdd6YndIg2R/KSbx+b5quD9v44V3cZ6OBMdddshCxzq/YEWsB6lCs/86ThbmWojJ7Vw==}
|
||||
'@pnpm/linuxstatic-arm64@11.5.0':
|
||||
resolution: {integrity: sha512-2nOnMW1rSwGv22q2yZz1HlGT3ly/Ij8wUlX0NB4n+Krx7nETRHA3MgWsbkVejxHknDcTulRVudAghuX9rgrXcw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.5.3':
|
||||
resolution: {integrity: sha512-05FvuSx6G1c+ZNs4j/TQldy09YFkfXkvq+MgFplEFIUguLShQi88gkvBBSt1v2RiIPdnjn/kHN7FmyL++RiXZw==}
|
||||
'@pnpm/linuxstatic-x64@11.5.0':
|
||||
resolution: {integrity: sha512-ONOC1Mg0JusHtjzkRlre9di1QO+GAjy4HP7jMjDx21yGhrSheNdUweTXbekMH1EflRd19kTU6d8M3zewJFPtVg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/macos-arm64@11.5.3':
|
||||
resolution: {integrity: sha512-nyQs/0yUZAHszkEGdDvOz964aPPzrMw+PGh6yIEYNMEzZfxSLEzD4No1qHj7AOplYmSB9tRazHp7o6einjE6pA==}
|
||||
'@pnpm/macos-arm64@11.5.0':
|
||||
resolution: {integrity: sha512-od0ALdTxs4A7s5vAH5q2l2phzCJb98+PVOW1rq7BGpWGeYxQ+EwvL+vq0KaO6iLsn/eVVoncCkgZ/k6QNYuTgw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/win-arm64@11.5.3':
|
||||
resolution: {integrity: sha512-7DOlug2CLCuRdWTar5ljDQNpvLLphJXqb3nDw2Vx+9vUhHjbfJRFrjZ+fYj5APoUUZv1esMaiAql4dNEqYVo1Q==}
|
||||
'@pnpm/win-arm64@11.5.0':
|
||||
resolution: {integrity: sha512-9HqbI80FjVVqFx4+EPxYYNfeP9Sx69W6kYqUDvOJn9G7RJ/2NNNQ898cVHTMpXlW1/PrMEcijmdpa/NjZIrWiQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/win-x64@11.5.3':
|
||||
resolution: {integrity: sha512-hnIv1nVlTjEr7H415bVEfNVH65sfHClvuWd35fkR1WLVut+dHBSKqQNeDd4auJCeFtiSSVvIingsNS63abAWUQ==}
|
||||
'@pnpm/win-x64@11.5.0':
|
||||
resolution: {integrity: sha512-Q89CQqFGAsWmfvHZs5Kbbar45q3GBYtfAdPUCiVMVNJoLi3dsBS2LCvUq8ak3AufkFDaJBpvhaFcDP2M1NXr3A==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -116,45 +116,45 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pnpm@11.5.3:
|
||||
resolution: {integrity: sha512-esHJGTQcITo03A0Cr7cUPFwmrCbujEeC3uqCG4rGTSE0oIH9iUHa5uKbu0j1jfwrf7zuzMB8svCdIZ00Kklp7Q==}
|
||||
pnpm@11.5.0:
|
||||
resolution: {integrity: sha512-2/zE+Bz0hZev1Lw5H/3xLBHxqfuDo5W/prCi2cwv2P/rr9scy9UpYyFT95OQTCYVt/Cf4aNFRz/Rw1hFFyqOsQ==}
|
||||
engines: {node: '>=22.13'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe@11.5.3':
|
||||
'@pnpm/exe@11.5.0':
|
||||
dependencies:
|
||||
'@reflink/reflink': 0.1.19
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@pnpm/linux-arm64': 11.5.3
|
||||
'@pnpm/linux-x64': 11.5.3
|
||||
'@pnpm/linuxstatic-arm64': 11.5.3
|
||||
'@pnpm/linuxstatic-x64': 11.5.3
|
||||
'@pnpm/macos-arm64': 11.5.3
|
||||
'@pnpm/win-arm64': 11.5.3
|
||||
'@pnpm/win-x64': 11.5.3
|
||||
'@pnpm/linux-arm64': 11.5.0
|
||||
'@pnpm/linux-x64': 11.5.0
|
||||
'@pnpm/linuxstatic-arm64': 11.5.0
|
||||
'@pnpm/linuxstatic-x64': 11.5.0
|
||||
'@pnpm/macos-arm64': 11.5.0
|
||||
'@pnpm/win-arm64': 11.5.0
|
||||
'@pnpm/win-x64': 11.5.0
|
||||
|
||||
'@pnpm/linux-arm64@11.5.3':
|
||||
'@pnpm/linux-arm64@11.5.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linux-x64@11.5.3':
|
||||
'@pnpm/linux-x64@11.5.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.5.3':
|
||||
'@pnpm/linuxstatic-arm64@11.5.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.5.3':
|
||||
'@pnpm/linuxstatic-x64@11.5.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/macos-arm64@11.5.3':
|
||||
'@pnpm/macos-arm64@11.5.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-arm64@11.5.3':
|
||||
'@pnpm/win-arm64@11.5.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-x64@11.5.3':
|
||||
'@pnpm/win-x64@11.5.0':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
@@ -194,7 +194,7 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
pnpm@11.5.3: {}
|
||||
pnpm@11.5.0: {}
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
@@ -242,8 +242,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.15
|
||||
version: 0.20.15
|
||||
specifier: 0.20.9
|
||||
version: 0.20.9
|
||||
'@atproto/syntax':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
@@ -272,8 +272,8 @@ importers:
|
||||
specifier: ^0.2.9
|
||||
version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/peek-menu':
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
specifier: ^0.2.4
|
||||
version: 0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/react-native-mmkv':
|
||||
specifier: 2.12.5
|
||||
version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -877,8 +877,8 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@atproto/api@0.20.15':
|
||||
resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==}
|
||||
'@atproto/api@0.20.9':
|
||||
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -1659,8 +1659,8 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky.app/peek-menu@0.3.0':
|
||||
resolution: {integrity: sha512-F51CT7xawnzoIOGA86E5VNYuWCItS1DnQr/J3eRE62E5+lbuCixSK/mQ4oqVsfQbKuYfAqMsBJjCgpCkdZTqTQ==}
|
||||
'@bsky.app/peek-menu@0.2.4':
|
||||
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
@@ -9493,7 +9493,7 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.15':
|
||||
'@atproto/api@0.20.9':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
@@ -10479,7 +10479,7 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
'@bsky.app/peek-menu@0.3.0(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
'@bsky.app/peek-menu@0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
|
||||
@@ -134,7 +134,6 @@ import {
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {setNavigationMetadata} from '#/analytics/metadata'
|
||||
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {InviteScannerScreen} from '#/features/inviteFriends'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
|
||||
@@ -310,11 +309,6 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
getComponent={() => DebugModScreen}
|
||||
options={{title: title(msg`Moderation states`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="InviteScanner"
|
||||
getComponent={() => InviteScannerScreen}
|
||||
options={{title: title(msg`Scan QR code`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SharedPreferencesTester"
|
||||
getComponent={() => SharedPreferencesTesterScreen}
|
||||
@@ -850,8 +844,6 @@ const LINKING = {
|
||||
},
|
||||
} satisfies LinkingOptions<AllNavigatorParams>
|
||||
|
||||
let didHandlePushNotificationEntry = false
|
||||
|
||||
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const ax = useAnalytics()
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
@@ -912,14 +904,6 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
function handlePushNotificationEntry() {
|
||||
if (!IS_NATIVE) return
|
||||
|
||||
// Only consume a launching notification once per JS runtime. Account
|
||||
// switches remount the entire tree (see `key={currentAccount?.did}` in
|
||||
// `App.native.tsx`), which re-fires `onNavigationReady` and would
|
||||
// otherwise re-process whatever `getLastNotificationResponse` still has
|
||||
// cached natively (APP-2338).
|
||||
if (didHandlePushNotificationEntry) return
|
||||
didHandlePushNotificationEntry = true
|
||||
|
||||
// intent urls are handled by `useIntentHandler`
|
||||
if (linkingUrl) return
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const prefetchAgeAssuranceServerData = () => {}
|
||||
export const prefetchAgeAssuranceData = () => {}
|
||||
export const setBirthdateForDid = () => {}
|
||||
export const setCreatedAtForDid = () => {}
|
||||
|
||||
@@ -32,7 +32,7 @@ import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||
import {
|
||||
isLegacyBirthdateBug,
|
||||
@@ -53,7 +53,7 @@ export function NoAccessScreen() {
|
||||
const birthdateControl = useDialogControl()
|
||||
const deactivateAccountControl = useDialogControl()
|
||||
const deleteAccountControl = useDialogControl()
|
||||
const {metadata} = useAgeAssuranceServerDataContext()
|
||||
const {data} = useAgeAssuranceDataContext()
|
||||
const region = useAgeAssuranceRegionConfig()
|
||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
@@ -62,15 +62,15 @@ export function NoAccessScreen() {
|
||||
const aa = useAgeAssurance()
|
||||
const isBlocked = aa.state.status === aa.Status.Blocked
|
||||
const isAARegion = !!region
|
||||
const hasDeclaredAge = metadata?.declaredAge !== undefined
|
||||
const hasDeclaredAge = data?.declaredAge !== undefined
|
||||
const canUpdateBirthday =
|
||||
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
|
||||
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
|
||||
|
||||
useEffect(() => {
|
||||
// just counting overall hits here
|
||||
ax.metric(`blockedGeoOverlay:shown`, {})
|
||||
ax.metric(`ageAssurance:noAccessScreen:shown`, {
|
||||
accountCreatedAt: metadata?.accountCreatedAt || 'unknown',
|
||||
accountCreatedAt: data?.accountCreatedAt || 'unknown',
|
||||
isAARegion,
|
||||
hasDeclaredAge,
|
||||
canUpdateBirthday,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
|
||||
/**
|
||||
* Minimum age required to access the app at all.
|
||||
*/
|
||||
export const MIN_ACCESS_AGE = 13
|
||||
|
||||
export const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
|
||||
countryCode: '*',
|
||||
regionCode: undefined,
|
||||
minAccessAge: MIN_ACCESS_AGE,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
age: MIN_ACCESS_AGE,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: AgeAssuranceAccess.None,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declar
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as debug from '#/ageAssurance/debug'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {type AgeAssuranceMetadata} from '#/ageAssurance/types'
|
||||
import {
|
||||
getBirthdateStringFromAge,
|
||||
isLegacyBirthdateBug,
|
||||
@@ -486,9 +485,9 @@ export function useOtherRequiredDataQuery() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to prefetch all age assurance data from the server.
|
||||
* Helper to prefetch all age assurance data.
|
||||
*/
|
||||
export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
|
||||
export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
|
||||
return Promise.allSettled([
|
||||
// config fetch initiated at the top of the App.platform.tsx files, awaited here
|
||||
configPrefetchPromise,
|
||||
@@ -497,8 +496,8 @@ export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
|
||||
])
|
||||
}
|
||||
|
||||
export function clearAgeAssuranceServerDataForDid({did}: {did: string}) {
|
||||
logger.debug(`clearAgeAssuranceServerDataForDid: ${did}`)
|
||||
export function clearAgeAssuranceDataForDid({did}: {did: string}) {
|
||||
logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
|
||||
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
|
||||
qc.removeQueries({
|
||||
queryKey: createOtherRequiredDataQueryKey({did}),
|
||||
@@ -506,8 +505,8 @@ export function clearAgeAssuranceServerDataForDid({did}: {did: string}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function clearAgeAssuranceServerDataForAll() {
|
||||
logger.debug(`clearAgeAssuranceServerDataForAll`)
|
||||
export function clearAgeAssuranceData() {
|
||||
logger.debug(`clearAgeAssuranceData`)
|
||||
qc.clear()
|
||||
}
|
||||
|
||||
@@ -515,30 +514,30 @@ export function clearAgeAssuranceServerDataForAll() {
|
||||
* Context
|
||||
*/
|
||||
|
||||
export type AgeAssuranceServerData = {
|
||||
/**
|
||||
* The raw config from the appview.
|
||||
*/
|
||||
export type AgeAssuranceData = {
|
||||
config: AppBskyAgeassuranceDefs.Config | undefined
|
||||
/**
|
||||
* The raw state from the appview. Must be further processed before being useful.
|
||||
*/
|
||||
state: AppBskyAgeassuranceDefs.State | undefined
|
||||
metadata: AgeAssuranceMetadata | undefined
|
||||
data:
|
||||
| {
|
||||
accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
|
||||
declaredAge: number | undefined
|
||||
birthdate: string | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||
export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
|
||||
config: undefined,
|
||||
state: undefined,
|
||||
metadata: {
|
||||
data: {
|
||||
accountCreatedAt: undefined,
|
||||
declaredAge: undefined,
|
||||
birthdate: undefined,
|
||||
},
|
||||
})
|
||||
export function useAgeAssuranceServerDataContext() {
|
||||
return useContext(AgeAssuranceServerDataContext)
|
||||
export function useAgeAssuranceDataContext() {
|
||||
return useContext(AgeAssuranceDataContext)
|
||||
}
|
||||
export function AgeAssuranceServerDataProvider({
|
||||
export function AgeAssuranceDataProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
@@ -551,8 +550,7 @@ export function AgeAssuranceServerDataProvider({
|
||||
() => ({
|
||||
config,
|
||||
state,
|
||||
metadata: {
|
||||
// yes, it's weird, but accountCreatedAt comes back on the `getState` endpoint
|
||||
data: {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: data?.birthdate
|
||||
? getAge(new Date(data.birthdate))
|
||||
@@ -563,8 +561,8 @@ export function AgeAssuranceServerDataProvider({
|
||||
[config, state, data, metadata],
|
||||
)
|
||||
return (
|
||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||
<AgeAssuranceDataContext.Provider value={ctx}>
|
||||
{children}
|
||||
</AgeAssuranceServerDataContext.Provider>
|
||||
</AgeAssuranceDataContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,8 +26,35 @@ export const deviceGeolocation: Geolocation | undefined =
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
regions: [
|
||||
{
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'BB',
|
||||
regionCode: undefined,
|
||||
minAccessAge: 16,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const otherRequiredData: OtherRequiredData = {
|
||||
birthdate: new Date(2010, 12, 1).toISOString(),
|
||||
birthdate: new Date(2000, 1, 1).toISOString(),
|
||||
}
|
||||
|
||||
const serverStateEnabled = false || IS_E2E
|
||||
@@ -45,218 +72,6 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
regions: [
|
||||
{
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'GB',
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 13,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'AU',
|
||||
minAccessAge: 16,
|
||||
rules: [
|
||||
{
|
||||
date: '2025-12-10T00:00:00Z',
|
||||
access: 'none',
|
||||
$type: ids.IfAccountNewerThan,
|
||||
},
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 16,
|
||||
access: 'safe',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 16,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'SD',
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 13,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'WY',
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 13,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'OH',
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 13,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'MS',
|
||||
minAccessAge: 18,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'VA',
|
||||
minAccessAge: 16,
|
||||
rules: [
|
||||
{
|
||||
age: 16,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 16,
|
||||
access: 'full',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'US',
|
||||
regionCode: 'TN',
|
||||
minAccessAge: 18,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'BR',
|
||||
minAccessAge: 13,
|
||||
rules: [
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfAssuredOverAge,
|
||||
},
|
||||
{
|
||||
age: 18,
|
||||
access: 'full',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
age: 13,
|
||||
access: 'safe',
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
},
|
||||
{
|
||||
access: 'none',
|
||||
$type: ids.Default,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export async function resolve<T>(data: T) {
|
||||
await new Promise(y => setTimeout(y, 500)) // simulate network
|
||||
return data
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import {createContext, useCallback, useContext, useMemo} from 'react'
|
||||
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||
|
||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {
|
||||
AgeAssuranceServerDataProvider,
|
||||
useAgeAssuranceServerDataContext,
|
||||
AgeAssuranceDataProvider,
|
||||
useAgeAssuranceDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {
|
||||
@@ -15,29 +14,37 @@ import {
|
||||
} from '#/ageAssurance/state'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceFlags,
|
||||
type AgeAssuranceState,
|
||||
AgeAssuranceStatus,
|
||||
} from '#/ageAssurance/types'
|
||||
import {
|
||||
computeAgeAssuranceFlags,
|
||||
isUnderAge,
|
||||
maybeRestrictChatSettings,
|
||||
MIN_ACCESS_AGE,
|
||||
useAgeAssuranceRegionConfigWithFallback,
|
||||
} from '#/ageAssurance/util'
|
||||
|
||||
export {
|
||||
prefetchConfig as prefetchAgeAssuranceConfig,
|
||||
prefetchAgeAssuranceServerData,
|
||||
prefetchAgeAssuranceData,
|
||||
refetchServerState as refetchAgeAssuranceServerState,
|
||||
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
|
||||
usePatchServerState as usePatchAgeAssuranceServerState,
|
||||
} from '#/ageAssurance/data'
|
||||
export {logger} from '#/ageAssurance/logger'
|
||||
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
|
||||
|
||||
const AgeAssuranceStateContext = createContext<{
|
||||
Access: typeof AgeAssuranceAccess
|
||||
Status: typeof AgeAssuranceStatus
|
||||
state: AgeAssuranceState
|
||||
flags: AgeAssuranceFlags
|
||||
flags: {
|
||||
adultContentDisabled: boolean
|
||||
chatDisabled: boolean
|
||||
isDeclaredUnderAdultAge: boolean
|
||||
isOverRegionMinAccessAge: boolean
|
||||
isOverAppMinAccessAge: boolean
|
||||
}
|
||||
}>({
|
||||
Access: AgeAssuranceAccess,
|
||||
Status: AgeAssuranceStatus,
|
||||
@@ -47,10 +54,8 @@ const AgeAssuranceStateContext = createContext<{
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
flags: {
|
||||
isAgeRestricted: false,
|
||||
adultContentDisabled: false,
|
||||
chatDisabled: false,
|
||||
groupChatDisabled: false,
|
||||
isDeclaredUnderAdultAge: false,
|
||||
isOverRegionMinAccessAge: false,
|
||||
isOverAppMinAccessAge: false,
|
||||
@@ -68,61 +73,65 @@ export function useAgeAssurance() {
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<AgeAssuranceServerDataProvider>
|
||||
<AgeAssuranceDataProvider>
|
||||
<InnerProvider>
|
||||
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
|
||||
</InnerProvider>
|
||||
</AgeAssuranceServerDataProvider>
|
||||
</AgeAssuranceDataProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
const agent = useAgent()
|
||||
const state = useAgeAssuranceState()
|
||||
const {metadata} = useAgeAssuranceServerDataContext()
|
||||
const regionConfig = useAgeAssuranceRegionConfigWithFallback()
|
||||
const {data} = useAgeAssuranceDataContext()
|
||||
const config = useAgeAssuranceRegionConfigWithFallback()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
|
||||
const handleAccessUpdate = useCallback(
|
||||
(s: AgeAssuranceState) => {
|
||||
const flags = computeAgeAssuranceFlags({
|
||||
state: s,
|
||||
regionConfig,
|
||||
metadata,
|
||||
})
|
||||
if (flags.isAgeRestricted) {
|
||||
void getAndRegisterPushToken({
|
||||
isAgeRestricted: true,
|
||||
})
|
||||
}
|
||||
if (flags.chatDisabled || flags.groupChatDisabled) {
|
||||
void restrictChatSettings({
|
||||
agent,
|
||||
restrictIncoming: flags.chatDisabled,
|
||||
restrictGroupInvites: flags.groupChatDisabled,
|
||||
})
|
||||
const isAgeRestricted = s.access !== AgeAssuranceAccess.Full
|
||||
if (isAgeRestricted) {
|
||||
void getAndRegisterPushToken({isAgeRestricted})
|
||||
maybeRestrictChatSettings({agent})
|
||||
}
|
||||
},
|
||||
[agent, getAndRegisterPushToken, regionConfig, metadata],
|
||||
[agent, getAndRegisterPushToken],
|
||||
)
|
||||
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug(`useAgeAssuranceState`, {state})
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<AgeAssuranceStateContext.Provider
|
||||
value={useMemo(() => {
|
||||
const res = {
|
||||
const chatDisabled = state.access !== AgeAssuranceAccess.Full
|
||||
const isDeclaredUnderAdultAge = data?.birthdate
|
||||
? isUnderAge(data.birthdate, 18)
|
||||
: true
|
||||
const isOverRegionMinAccessAge = data?.birthdate
|
||||
? !isUnderAge(data.birthdate, config.minAccessAge)
|
||||
: false
|
||||
const isOverAppMinAccessAge = data?.birthdate
|
||||
? !isUnderAge(data.birthdate, MIN_ACCESS_AGE)
|
||||
: false
|
||||
const adultContentDisabled =
|
||||
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
|
||||
return {
|
||||
Access: AgeAssuranceAccess,
|
||||
Status: AgeAssuranceStatus,
|
||||
state,
|
||||
flags: computeAgeAssuranceFlags({
|
||||
state,
|
||||
regionConfig,
|
||||
metadata,
|
||||
}),
|
||||
flags: {
|
||||
adultContentDisabled,
|
||||
chatDisabled,
|
||||
isDeclaredUnderAdultAge,
|
||||
isOverRegionMinAccessAge,
|
||||
isOverAppMinAccessAge,
|
||||
},
|
||||
}
|
||||
logger.debug(`useAgeAssurance`, res)
|
||||
return res
|
||||
}, [state, metadata, regionConfig])}>
|
||||
}, [state, data, config])}>
|
||||
{children}
|
||||
</AgeAssuranceStateContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {
|
||||
type AppBskyAgeassuranceDefs,
|
||||
computeAgeAssuranceRegionAccess,
|
||||
} from '@atproto/api'
|
||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
type AgeAssuranceData,
|
||||
getConfigFromCache,
|
||||
getOtherRequiredDataFromCache,
|
||||
getServerStateFromCache,
|
||||
useAgeAssuranceServerDataContext,
|
||||
useAgeAssuranceDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceMetadata,
|
||||
type AgeAssuranceState,
|
||||
AgeAssuranceStatus,
|
||||
parseAccessFromString,
|
||||
parseStatusFromString,
|
||||
} from '#/ageAssurance/types'
|
||||
import {
|
||||
computeAgeAssuranceFlags,
|
||||
getAgeAssuranceRegionConfigWithFallback,
|
||||
} from '#/ageAssurance/util'
|
||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
import {device} from '#/storage'
|
||||
|
||||
@@ -33,18 +27,18 @@ import {device} from '#/storage'
|
||||
* server state before computing access based on AA config from the server +
|
||||
* geolocation and other data.
|
||||
*/
|
||||
function computeAgeAssuranceState({
|
||||
export function computeAgeAssuranceState({
|
||||
hasSession,
|
||||
geolocation,
|
||||
config,
|
||||
geolocation,
|
||||
state,
|
||||
metadata,
|
||||
data,
|
||||
}: {
|
||||
hasSession: boolean
|
||||
config: AgeAssuranceData['config']
|
||||
geolocation: Geolocation
|
||||
config?: AppBskyAgeassuranceDefs.Config
|
||||
state?: AppBskyAgeassuranceDefs.State
|
||||
metadata?: AgeAssuranceMetadata
|
||||
state: AgeAssuranceData['state']
|
||||
data: AgeAssuranceData['data']
|
||||
}) {
|
||||
/**
|
||||
* This is where we control logged-out moderation prefs. It's all
|
||||
@@ -94,10 +88,7 @@ function computeAgeAssuranceState({
|
||||
* accounts with an accurate birthdate, our default fallback rules should
|
||||
* ensure correct access.
|
||||
*/
|
||||
const result = computeAgeAssuranceRegionAccess(region, {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: metadata?.declaredAge,
|
||||
})
|
||||
const result = computeAgeAssuranceRegionAccess(region, data)
|
||||
const computed = {
|
||||
lastInitiatedAt: state?.lastInitiatedAt,
|
||||
// prefer server state
|
||||
@@ -109,10 +100,10 @@ function computeAgeAssuranceState({
|
||||
? parseAccessFromString(result.access)
|
||||
: AgeAssuranceAccess.Full,
|
||||
}
|
||||
logger.debug('computeAgeAssuranceState', {
|
||||
logger.debug('debug useAgeAssuranceState', {
|
||||
region,
|
||||
state,
|
||||
metadata,
|
||||
data,
|
||||
computed,
|
||||
})
|
||||
return computed
|
||||
@@ -122,51 +113,38 @@ function computeAgeAssuranceState({
|
||||
* This is a last-ditch helper for out-of-band reads of the AA state, such as
|
||||
* during account creation. Don't use it for anything else.
|
||||
*/
|
||||
export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
export function getAndComputeAgeAssuranceState({did}: {did: string}) {
|
||||
const config = getConfigFromCache()
|
||||
const state = getServerStateFromCache({did})
|
||||
const requiredData = getOtherRequiredDataFromCache({did})
|
||||
const data = getOtherRequiredDataFromCache({did})
|
||||
const geolocation = device.get(['mergedGeolocation'])
|
||||
|
||||
if (!geolocation || !config || !state || !requiredData) {
|
||||
if (!geolocation || !config || !state || !data) {
|
||||
return {
|
||||
state: {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Safe,
|
||||
},
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Safe,
|
||||
}
|
||||
}
|
||||
|
||||
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
|
||||
const metadata: AgeAssuranceMetadata = {
|
||||
accountCreatedAt: state.metadata?.accountCreatedAt,
|
||||
declaredAge: requiredData?.birthdate
|
||||
? getAge(new Date(requiredData.birthdate))
|
||||
: undefined,
|
||||
birthdate: requiredData?.birthdate,
|
||||
}
|
||||
const computed = computeAgeAssuranceState({
|
||||
return computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
config,
|
||||
geolocation,
|
||||
state: state.state,
|
||||
metadata,
|
||||
data: {
|
||||
accountCreatedAt: state.metadata?.accountCreatedAt,
|
||||
declaredAge: data?.birthdate
|
||||
? getAge(new Date(data.birthdate))
|
||||
: undefined,
|
||||
birthdate: data?.birthdate,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
state: computed,
|
||||
flags: computeAgeAssuranceFlags({
|
||||
state: computed,
|
||||
regionConfig: region,
|
||||
metadata,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
const {hasSession} = useSession()
|
||||
const geolocation = useGeolocation()
|
||||
const {config, state, metadata} = useAgeAssuranceServerDataContext()
|
||||
const {config, state, data} = useAgeAssuranceDataContext()
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -175,9 +153,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
config,
|
||||
geolocation,
|
||||
state,
|
||||
metadata,
|
||||
data,
|
||||
}),
|
||||
[hasSession, geolocation, config, state, metadata],
|
||||
[hasSession, geolocation, config, state, data],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
|
||||
export enum AgeAssuranceAccess {
|
||||
@@ -16,12 +14,6 @@ export enum AgeAssuranceStatus {
|
||||
Blocked = 'blocked',
|
||||
}
|
||||
|
||||
export type AgeAssuranceMetadata = Parameters<
|
||||
typeof computeAgeAssuranceRegionAccess
|
||||
>[1] & {
|
||||
birthdate: string | undefined
|
||||
}
|
||||
|
||||
export type AgeAssuranceState = {
|
||||
lastInitiatedAt?: string
|
||||
status: AgeAssuranceStatus
|
||||
@@ -29,16 +21,6 @@ export type AgeAssuranceState = {
|
||||
error?: 'config' // maybe other specific cases in the future
|
||||
}
|
||||
|
||||
export type AgeAssuranceFlags = {
|
||||
isAgeRestricted: boolean
|
||||
adultContentDisabled: boolean
|
||||
chatDisabled: boolean
|
||||
groupChatDisabled: boolean
|
||||
isDeclaredUnderAdultAge: boolean
|
||||
isOverRegionMinAccessAge: boolean
|
||||
isOverAppMinAccessAge: boolean
|
||||
}
|
||||
|
||||
export function parseStatusFromString(raw: string) {
|
||||
switch (raw) {
|
||||
case 'unknown':
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {useCallback} from 'react'
|
||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
|
||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
|
||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||
import {type Geolocation} from '#/geolocation'
|
||||
|
||||
export function useComputeAgeAssuranceRegionAccess() {
|
||||
const {config, metadata} = useAgeAssuranceServerDataContext()
|
||||
const {config, data} = useAgeAssuranceDataContext()
|
||||
return useCallback(
|
||||
(geolocation: Geolocation) => {
|
||||
if (!config) {
|
||||
@@ -19,14 +19,11 @@ export function useComputeAgeAssuranceRegionAccess() {
|
||||
config,
|
||||
geolocation,
|
||||
)
|
||||
const result = computeAgeAssuranceRegionAccess(region, {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: metadata?.declaredAge,
|
||||
})
|
||||
const result = computeAgeAssuranceRegionAccess(region, data)
|
||||
return result
|
||||
? parseAccessFromString(result.access)
|
||||
: AgeAssuranceAccess.Full
|
||||
},
|
||||
[config, metadata],
|
||||
[config, data],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import {useMemo} from 'react'
|
||||
import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
type AtpAgent,
|
||||
getAgeAssuranceRegionConfig,
|
||||
type ModerationPrefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
|
||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceFlags,
|
||||
type AgeAssuranceMetadata,
|
||||
type AgeAssuranceState,
|
||||
} from '#/ageAssurance/types'
|
||||
getDidFromAgentSession,
|
||||
getOtherRequiredDataFromCache,
|
||||
useAgeAssuranceDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
|
||||
export const MIN_ACCESS_AGE = 13
|
||||
const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
|
||||
countryCode: '*',
|
||||
regionCode: undefined,
|
||||
minAccessAge: MIN_ACCESS_AGE,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
age: MIN_ACCESS_AGE,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: AgeAssuranceAccess.None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age assurance region config based on geolocation, with fallback to
|
||||
* app defaults if no region config is found.
|
||||
@@ -43,7 +62,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
|
||||
*/
|
||||
export function useAgeAssuranceRegionConfig() {
|
||||
const geolocation = useGeolocation()
|
||||
const {config} = useAgeAssuranceServerDataContext()
|
||||
const {config} = useAgeAssuranceDataContext()
|
||||
return useMemo(() => {
|
||||
if (!config) return
|
||||
// use generic helper, we want to potentially return undefined
|
||||
@@ -97,37 +116,15 @@ export const makeAgeRestrictedModerationPrefs = (
|
||||
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
||||
})
|
||||
|
||||
export function computeAgeAssuranceFlags({
|
||||
state,
|
||||
regionConfig,
|
||||
metadata,
|
||||
}: {
|
||||
state: AgeAssuranceState
|
||||
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
|
||||
metadata?: AgeAssuranceMetadata
|
||||
}): AgeAssuranceFlags {
|
||||
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
|
||||
const chatDisabled = isAgeRestricted
|
||||
const isDeclaredUnderAdultAge = metadata?.declaredAge
|
||||
? metadata.declaredAge < 18
|
||||
: true
|
||||
const groupChatDisabled = chatDisabled || isDeclaredUnderAdultAge
|
||||
const isOverRegionMinAccessAge = metadata?.declaredAge
|
||||
? metadata.declaredAge >= regionConfig.minAccessAge
|
||||
: false
|
||||
const isOverAppMinAccessAge = metadata?.declaredAge
|
||||
? metadata.declaredAge >= MIN_ACCESS_AGE
|
||||
: false
|
||||
const adultContentDisabled =
|
||||
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
|
||||
|
||||
return {
|
||||
isAgeRestricted,
|
||||
adultContentDisabled,
|
||||
chatDisabled,
|
||||
groupChatDisabled,
|
||||
isDeclaredUnderAdultAge,
|
||||
isOverRegionMinAccessAge,
|
||||
isOverAppMinAccessAge,
|
||||
}
|
||||
/**
|
||||
* Checks our cache of the actor's chat declaration record, and if it's not
|
||||
* already restricted, restricts it.
|
||||
*/
|
||||
export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (!did) return
|
||||
const data = getOtherRequiredDataFromCache({did})
|
||||
// ...update the chat setting record if allowIncoming is not already 'none'.
|
||||
if (data?.actorDeclaration?.allowIncoming === 'none') return
|
||||
restrictChatSettings({agent, did})
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ export enum Features {
|
||||
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
GroupChatsDisable = 'group_chats:disable',
|
||||
GroupChatsEnable = 'group_chats:enable',
|
||||
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -586,86 +586,9 @@ export type Events = {
|
||||
| 'SendViaChatDialog'
|
||||
| 'ConvoSettings'
|
||||
}
|
||||
// Message replies
|
||||
'chat:message:reply:send': {
|
||||
convoId: string
|
||||
isGroup: boolean
|
||||
}
|
||||
'chat:message:reply:tap': {
|
||||
convoId: string
|
||||
}
|
||||
|
||||
// Group chat adoption
|
||||
'groupchat:create': {
|
||||
logContext: 'NewChatDialog'
|
||||
}
|
||||
'groupchat:landingPage:view': {
|
||||
hasSession: boolean
|
||||
}
|
||||
'groupchat:inviteLink:redeem': {}
|
||||
|
||||
// Group chat user interactions
|
||||
'groupchat:message:send': {
|
||||
convoId: string
|
||||
isOwner: boolean
|
||||
}
|
||||
'groupchat:mute': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:unmute': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:leave': {
|
||||
convoId: string
|
||||
isOwner: boolean
|
||||
}
|
||||
'groupchat:settings:view': {
|
||||
convoId: string
|
||||
isOwner: boolean
|
||||
}
|
||||
'groupchat:inviteLink:shareButton:press': {
|
||||
convoId: string
|
||||
method: 'post' | 'copy' | 'native'
|
||||
}
|
||||
'groupchat:inviteLink:shared': {
|
||||
convoId: string
|
||||
method: 'post' | 'dm'
|
||||
}
|
||||
|
||||
// Group chat owner actions
|
||||
'groupchat:owner:editName': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:lock': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:unlock': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:kickMember': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:inviteMember': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:joinRequest:accept': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:joinRequest:reject': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:inviteLink:create': {
|
||||
convoId: string
|
||||
}
|
||||
'groupchat:owner:inviteLink:disable': {
|
||||
convoId: string
|
||||
}
|
||||
|
||||
// Group chat problems
|
||||
'groupchat:join:memberLimitReached': {
|
||||
convoId: string
|
||||
}
|
||||
|
||||
'starterPack:addUser': {
|
||||
starterPack?: string
|
||||
}
|
||||
@@ -1285,43 +1208,4 @@ export type Events = {
|
||||
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} from 'react'
|
||||
import {useEffect, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -8,10 +8,12 @@ import Animated, {
|
||||
withDelay,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {moderateProfile} from '@atproto/api'
|
||||
import {
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
type ModerationUI,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -31,10 +33,10 @@ export function AvatarBubbles({
|
||||
profiles: allProfiles,
|
||||
self = false,
|
||||
size = 120,
|
||||
count,
|
||||
moderationOpts,
|
||||
}: {
|
||||
animate?: boolean
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
profiles: (bsky.profile.AnyProfileView | undefined)[]
|
||||
/**
|
||||
* By default, when there are more than 2 profiles, the current user is
|
||||
* filtered out (so you don't see yourself among your own group's members).
|
||||
@@ -43,21 +45,19 @@ export function AvatarBubbles({
|
||||
*/
|
||||
self?: boolean
|
||||
size?: number
|
||||
/**
|
||||
* 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
|
||||
moderationOpts?: ModerationOpts
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const profiles =
|
||||
!self && allProfiles.length > 2
|
||||
? allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
|
||||
: allProfiles
|
||||
|
||||
const bubbleCount = Math.max(profiles.length, count ?? 0)
|
||||
const moderations = useMemo(() => {
|
||||
if (!moderationOpts) return []
|
||||
return profiles.map(p => {
|
||||
return p && moderateProfile(p, moderationOpts)
|
||||
})
|
||||
}, [profiles, moderationOpts])
|
||||
|
||||
const scale = size / 120
|
||||
const marginOffset = size < 120 ? -2 : 0
|
||||
@@ -89,7 +89,7 @@ export function AvatarBubbles({
|
||||
}, [animate, p0, p1, p2, p3])
|
||||
|
||||
const scales = [p0, p1, p2, p3]
|
||||
const layouts = getLayouts(bubbleCount)
|
||||
const layouts = getLayouts(profiles.length)
|
||||
|
||||
return (
|
||||
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
|
||||
@@ -110,6 +110,7 @@ export function AvatarBubbles({
|
||||
y={layout.y}
|
||||
zIndex={layout.zIndex}
|
||||
includeProfileBorder={layout.border}
|
||||
moderation={moderations[i]?.ui('avatar')}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -118,13 +119,14 @@ export function AvatarBubbles({
|
||||
}
|
||||
|
||||
function AvatarBubble({
|
||||
profile: profileUnshadowed,
|
||||
profile,
|
||||
scale,
|
||||
size,
|
||||
x,
|
||||
y,
|
||||
zIndex,
|
||||
includeProfileBorder,
|
||||
moderation,
|
||||
}: {
|
||||
profile?: bsky.profile.AnyProfileView
|
||||
scale: SharedValue<number>
|
||||
@@ -133,15 +135,14 @@ function AvatarBubble({
|
||||
y: number
|
||||
zIndex?: number
|
||||
includeProfileBorder?: boolean
|
||||
moderation?: ModerationUI
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
|
||||
}))
|
||||
|
||||
const profile = useMaybeProfileShadow(profileUnshadowed)
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
@@ -155,14 +156,14 @@ function AvatarBubble({
|
||||
zIndex != null && {zIndex},
|
||||
animatedStyle,
|
||||
]}>
|
||||
{profile && moderationOpts ? (
|
||||
{profile ? (
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={size}
|
||||
type="user"
|
||||
hideLiveBadge
|
||||
noBorder
|
||||
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
|
||||
moderation={moderation}
|
||||
/>
|
||||
) : (
|
||||
<AvatarPlaceholder size={size} />
|
||||
|
||||
@@ -850,11 +850,10 @@ export function Item({
|
||||
!unstyled && [
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.px_lg,
|
||||
a.gap_sm,
|
||||
a.px_2xl,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
{minHeight: 44, paddingVertical: 10},
|
||||
{gap: 6, minHeight: 44, paddingVertical: 10},
|
||||
(focused || pressed || context.hoveredMenuItem === id) &&
|
||||
!rest.disabled &&
|
||||
t.atoms.bg_contrast_50,
|
||||
@@ -883,7 +882,8 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_medium,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
style,
|
||||
destructive && {color: t.palette.negative_500},
|
||||
disabled && t.atoms.text_contrast_low,
|
||||
@@ -898,13 +898,13 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||
const {disabled, destructive} = useContextMenuItemContext()
|
||||
return (
|
||||
<Comp
|
||||
size="md"
|
||||
size="lg"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: destructive
|
||||
? t.palette.negative_500
|
||||
: t.atoms.text.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -218,7 +218,7 @@ export function Inner({children, style, header}: DialogInnerProps) {
|
||||
|
||||
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||
function ScrollableInner(
|
||||
{children, contentContainerStyle, header, footer, style, ...props},
|
||||
{children, contentContainerStyle, header, style, ...props},
|
||||
ref,
|
||||
) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
|
||||
@@ -248,45 +248,42 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
style={[isHeightConstrained && a.flex_1, style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
platform({
|
||||
ios: a.pb_2xl,
|
||||
android: {
|
||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||
},
|
||||
}),
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
// set drag state based on scroll on android.
|
||||
// we want to detect if it's at the top or not, so watch
|
||||
// scrollEndDrag and momentumScrollEnd as well
|
||||
onScroll={android(onScroll)}
|
||||
onScrollEndDrag={android(onScroll)}
|
||||
onMomentumScrollEnd={android(onScroll)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
// dialogs that use this that actually scroll -sfn
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</ScrollView>
|
||||
{footer}
|
||||
</>
|
||||
<ScrollView
|
||||
style={[isHeightConstrained && a.flex_1, style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
platform({
|
||||
ios: a.pb_2xl,
|
||||
android: {
|
||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||
},
|
||||
}),
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
// set drag state based on scroll on android.
|
||||
// we want to detect if it's at the top or not, so watch
|
||||
// scrollEndDrag and momentumScrollEnd as well
|
||||
onScroll={android(onScroll)}
|
||||
onScrollEndDrag={android(onScroll)}
|
||||
onMomentumScrollEnd={android(onScroll)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
// dialogs that use this that actually scroll -sfn
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</ScrollView>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -353,11 +350,9 @@ export const InnerFlatList = forwardRef<
|
||||
export function FlatListFooter({
|
||||
children,
|
||||
onLayout,
|
||||
border = true,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
border?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {bottom} = useSafeAreaInsets()
|
||||
@@ -378,7 +373,7 @@ export function FlatListFooter({
|
||||
a.bottom_0,
|
||||
a.w_full,
|
||||
a.z_10,
|
||||
border && a.border_t,
|
||||
a.border_t,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
|
||||
@@ -170,7 +170,6 @@ export function Inner({
|
||||
accessibilityLabelledBy,
|
||||
accessibilityDescribedBy,
|
||||
header,
|
||||
footer,
|
||||
contentContainerStyle,
|
||||
}: DialogInnerProps) {
|
||||
const t = useTheme()
|
||||
@@ -217,7 +216,6 @@ export function Inner({
|
||||
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
{footer}
|
||||
</DismissableLayer.DismissableLayer>
|
||||
</View>
|
||||
</FocusScope.FocusScope>
|
||||
@@ -268,11 +266,9 @@ export const InnerFlatList = forwardRef<
|
||||
export function FlatListFooter({
|
||||
children,
|
||||
onLayout,
|
||||
border = true,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
border?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -285,7 +281,7 @@ export function FlatListFooter({
|
||||
a.w_full,
|
||||
a.z_10,
|
||||
t.atoms.bg,
|
||||
border && a.border_t,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
|
||||
@@ -79,18 +79,14 @@ export type DialogInnerProps =
|
||||
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
||||
accessibilityDescribedBy: string
|
||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
header?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}>
|
||||
| DialogInnerPropsBase<{
|
||||
label: string
|
||||
accessibilityLabelledBy?: undefined
|
||||
accessibilityDescribedBy?: undefined
|
||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
header?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {android, atoms as a, ios} from '#/alf'
|
||||
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||
import {type Props as IconProps} from '#/components/icons/common'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
|
||||
@@ -25,6 +25,7 @@ type Props = {
|
||||
|
||||
type Anchor = {x: number; y: number; width: number; height: number}
|
||||
|
||||
const MENU_WIDTH = 160
|
||||
const GAP = 6
|
||||
const CARD_BG = '#000000'
|
||||
const CARD_BORDER = '#232e3e'
|
||||
@@ -123,8 +124,9 @@ function MenuCard({
|
||||
<Animated.View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.self_start,
|
||||
styles.card,
|
||||
android({alignSelf: 'flex-start'}),
|
||||
ios({width: MENU_WIDTH}),
|
||||
{
|
||||
top: anchor.y + anchor.height + GAP,
|
||||
left: anchor.x,
|
||||
@@ -184,6 +186,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
itemText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
lineHeight: 19.5,
|
||||
|
||||
@@ -91,9 +91,6 @@ export default function ImageViewRoot({
|
||||
'use no memo'
|
||||
const ref = useAnimatedRef<View>()
|
||||
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'>(
|
||||
'portrait',
|
||||
)
|
||||
@@ -104,7 +101,6 @@ export default function ImageViewRoot({
|
||||
|
||||
if (!activeLightbox && nextLightbox) {
|
||||
setActiveLightbox(nextLightbox)
|
||||
setImageIndex(nextLightbox.index)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -197,8 +193,6 @@ export default function ImageViewRoot({
|
||||
<ImageView
|
||||
key={activeLightbox.id + '-' + orientation}
|
||||
lightbox={activeLightbox}
|
||||
imageIndex={imageIndex}
|
||||
setImageIndex={setImageIndex}
|
||||
orientation={orientation}
|
||||
onRequestClose={onRequestClose}
|
||||
onPressSave={onPressSave}
|
||||
@@ -216,8 +210,6 @@ export default function ImageViewRoot({
|
||||
|
||||
function ImageView({
|
||||
lightbox,
|
||||
imageIndex,
|
||||
setImageIndex,
|
||||
orientation,
|
||||
onRequestClose,
|
||||
onPressSave,
|
||||
@@ -228,8 +220,6 @@ function ImageView({
|
||||
thumbRects,
|
||||
}: {
|
||||
lightbox: Lightbox
|
||||
imageIndex: number
|
||||
setImageIndex: React.Dispatch<React.SetStateAction<number>>
|
||||
orientation: 'portrait' | 'landscape'
|
||||
onRequestClose: () => void
|
||||
onPressSave: (uri: string) => void
|
||||
@@ -239,14 +229,12 @@ function ImageView({
|
||||
openProgress: SharedValue<number>
|
||||
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
||||
}) {
|
||||
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 {images, index: initialImageIndex, metricsContext} = lightbox
|
||||
const ax = useAnalytics()
|
||||
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [imageIndex, setImageIndex] = useState(initialImageIndex)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [isAltExpanded, setIsAltExpanded] = useState(false)
|
||||
const dismissSwipeTranslateY = useSharedValue(0)
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type TargetedEvent,
|
||||
} from 'react-native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {
|
||||
type LinkProps as RNLinkProps,
|
||||
StackActions,
|
||||
@@ -27,14 +26,11 @@ import {
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useInAppBrowser} from '#/state/preferences/in-app-browser'
|
||||
import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {Button, type ButtonProps} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||
import * as PeekMenu from '#/components/PeekMenu'
|
||||
import {Text, type TextProps} from '#/components/Typography'
|
||||
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {router} from '#/routes'
|
||||
import {useGlobalDialogsControlContext} from './dialogs/Context'
|
||||
|
||||
@@ -90,13 +86,6 @@ type BaseLinkProps = {
|
||||
*/
|
||||
shouldProxy?: boolean
|
||||
|
||||
/**
|
||||
* iOS only. Wraps the link in a peek menu: long-pressing previews the live
|
||||
* page in an in-app browser (morphing into it on tap when the in-app browser
|
||||
* preference is on), with a share action in the menu. No-op elsewhere.
|
||||
*/
|
||||
peek?: boolean
|
||||
|
||||
/**
|
||||
* Web only
|
||||
*/
|
||||
@@ -286,19 +275,11 @@ export function useLink({
|
||||
[outerOnLongPress, handleLongPress, shareOnLongPress],
|
||||
)
|
||||
|
||||
// Opens the link through the normal external flow (consent dialog, in-app
|
||||
// browser, or system browser per preference). Used by the peek menu when the
|
||||
// in-app browser is off, so committing the peek behaves like a plain tap.
|
||||
const openExternally = useCallback(() => {
|
||||
void openLink(href, overridePresentation, shouldProxy)
|
||||
}, [openLink, href, overridePresentation, shouldProxy])
|
||||
|
||||
return {
|
||||
isExternal,
|
||||
href,
|
||||
onPress,
|
||||
onLongPress,
|
||||
openExternally,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,10 +305,9 @@ export function Link({
|
||||
download,
|
||||
shouldProxy,
|
||||
overridePresentation,
|
||||
peek,
|
||||
...rest
|
||||
}: LinkProps) {
|
||||
const {href, isExternal, onPress, onLongPress, openExternally} = useLink({
|
||||
const {href, isExternal, onPress, onLongPress} = useLink({
|
||||
to,
|
||||
displayText: typeof children === 'string' ? children : '',
|
||||
action,
|
||||
@@ -337,10 +317,7 @@ export function Link({
|
||||
overridePresentation,
|
||||
})
|
||||
|
||||
// Peek is iOS-only and only makes sense for external web links.
|
||||
const peekEnabled = Boolean(peek && IS_IOS && isExternal)
|
||||
|
||||
const button = (
|
||||
return (
|
||||
<Button
|
||||
{...rest}
|
||||
style={[a.justify_start, rest.style]}
|
||||
@@ -348,11 +325,7 @@ export function Link({
|
||||
accessibilityRole="link"
|
||||
href={href}
|
||||
onPress={download ? undefined : onPress}
|
||||
// When peeking, the native long-press drives the context menu. A
|
||||
// simultaneous RN long-press recognizer fights it — cancelling the tap
|
||||
// and breaking the lift animation — so leave long-press to native and
|
||||
// surface sharing through the peek menu instead.
|
||||
onLongPress={peekEnabled ? undefined : onLongPress}
|
||||
onLongPress={onLongPress}
|
||||
{...web({
|
||||
hrefAttrs: {
|
||||
target: download ? undefined : isExternal ? 'blank' : undefined,
|
||||
@@ -367,74 +340,6 @@ export function Link({
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (peekEnabled) {
|
||||
// Match the lift radius to whatever the consumer styled the link with, so
|
||||
// the peek animation clips to the same corners as the rendered card.
|
||||
const borderRadius = flatten(rest.style)?.borderRadius
|
||||
return (
|
||||
<LinkPeek
|
||||
href={href}
|
||||
onPreviewPress={openExternally}
|
||||
shouldProxy={shouldProxy}
|
||||
borderRadius={typeof borderRadius === 'number' ? borderRadius : 0}>
|
||||
{button}
|
||||
</LinkPeek>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS peek-menu wrapper for an external `Link`. Long-pressing previews the live
|
||||
* page in an in-app browser; the in-app-browser preference decides whether
|
||||
* tapping the peek morphs into the browser or hands off to the normal link flow
|
||||
* via `onPreviewPress`. The menu carries a Share action.
|
||||
*/
|
||||
function LinkPeek({
|
||||
href,
|
||||
onPreviewPress,
|
||||
shouldProxy,
|
||||
borderRadius,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
onPreviewPress: () => void
|
||||
shouldProxy?: boolean
|
||||
borderRadius: number
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const useInAppBrowserPref = useInAppBrowser()
|
||||
|
||||
return (
|
||||
<PeekMenu.Root>
|
||||
<PeekMenu.Trigger
|
||||
preview={{
|
||||
type: 'link',
|
||||
url: shouldProxy ? createProxiedUrl(href) : href,
|
||||
// Only morph natively when the user has explicitly opted in. When the
|
||||
// preference is unset (undefined), defer to the JS flow so the consent
|
||||
// dialog can show.
|
||||
useInAppBrowser: useInAppBrowserPref === true,
|
||||
browserToolbarColor: t.atoms.bg.backgroundColor,
|
||||
browserControlsColor: t.palette.primary_500,
|
||||
}}
|
||||
borderRadius={borderRadius}
|
||||
// Fires only when not morphing natively (in-app browser off/unset).
|
||||
onPreviewPress={onPreviewPress}>
|
||||
{children}
|
||||
</PeekMenu.Trigger>
|
||||
<PeekMenu.Menu>
|
||||
<PeekMenu.MenuItem id="share" onSelect={() => void shareUrl(href)}>
|
||||
<PeekMenu.MenuItemIcon icon={ShareIcon} />
|
||||
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
|
||||
</PeekMenu.MenuItem>
|
||||
</PeekMenu.Menu>
|
||||
</PeekMenu.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineLinkProps = React.PropsWithChildren<
|
||||
|
||||
@@ -154,7 +154,7 @@ export function ImageItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_grow, a.relative, a.aspect_square, {maxWidth}]}>
|
||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth}]}>
|
||||
<Image
|
||||
key={thumbnail}
|
||||
source={{uri: thumbnail}}
|
||||
|
||||
@@ -38,9 +38,9 @@ function ChatInviteEmbedBody({
|
||||
onOpen?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {status} = ChatInvite.useChatInvite()
|
||||
const {error} = ChatInvite.useChatInvite()
|
||||
|
||||
if (status === 'error') {
|
||||
if (error) {
|
||||
return <ExternalEmbed link={link} onOpen={onOpen} style={style} />
|
||||
}
|
||||
|
||||
|
||||
@@ -88,8 +88,6 @@ export const ExternalEmbed = ({
|
||||
label={link.title || _(msg`Open link to ${niceUrl}`)}
|
||||
to={link.uri}
|
||||
shouldProxy={true}
|
||||
peek
|
||||
style={[a.rounded_md]}
|
||||
onPress={onPress}
|
||||
onLongPress={onShareExternal}>
|
||||
{({hovered}) => (
|
||||
@@ -101,7 +99,6 @@ export const ExternalEmbed = ({
|
||||
a.overflow_hidden,
|
||||
a.w_full,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
style,
|
||||
hovered
|
||||
? t.atoms.border_contrast_high
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {type ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
type ChatInvitePreview,
|
||||
isKnownJoinLinkPreview,
|
||||
} from '#/state/queries/join-links'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const JOIN_REQUEST_EMBED_HEIGHT = 140
|
||||
|
||||
@@ -22,12 +23,11 @@ export function JoinRequestEmbed({
|
||||
onOpen,
|
||||
}: {
|
||||
code?: string
|
||||
preview?: ChatInvitePreview
|
||||
preview?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||
style?: StyleProp<ViewStyle>
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const resolvedCode =
|
||||
code ?? (isKnownJoinLinkPreview(preview) ? preview.code : undefined)
|
||||
const resolvedCode = code ?? preview?.code
|
||||
if (!resolvedCode) return null
|
||||
|
||||
return (
|
||||
@@ -53,29 +53,62 @@ export function JoinRequestEmbedBody({
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {status} = ChatInvite.useChatInvite()
|
||||
const {loading, preview} = 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 (loading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.p_lg,
|
||||
a.border,
|
||||
a.rounded_lg,
|
||||
t.atoms.border_contrast_high,
|
||||
{height: JOIN_REQUEST_EMBED_HEIGHT},
|
||||
style,
|
||||
]}>
|
||||
<Loader size="md" fill={t.atoms.text.color} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (status !== 'available') {
|
||||
if (!preview) {
|
||||
return (
|
||||
<ChatInvite.Unavailable
|
||||
style={[box, a.p_lg, t.atoms.bg_contrast_25, style]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.p_lg,
|
||||
a.gap_xs,
|
||||
a.border,
|
||||
a.rounded_lg,
|
||||
t.atoms.border_contrast_high,
|
||||
t.atoms.bg_contrast_25,
|
||||
{height: JOIN_REQUEST_EMBED_HEIGHT},
|
||||
style,
|
||||
]}>
|
||||
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
|
||||
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Chat invite link no longer available</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.justify_between, a.p_lg, a.gap_lg, box, style]}>
|
||||
<View
|
||||
style={[
|
||||
a.justify_between,
|
||||
a.border,
|
||||
a.rounded_lg,
|
||||
a.p_lg,
|
||||
a.gap_lg,
|
||||
t.atoms.border_contrast_high,
|
||||
{height: JOIN_REQUEST_EMBED_HEIGHT},
|
||||
style,
|
||||
]}>
|
||||
<ChatInvite.Card size="large" />
|
||||
<ChatInvite.JoinButton onPress={onOpen} />
|
||||
</View>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {Fragment, type ReactNode} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {StandardSite} from '#/components/icons/community/StandardSite'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {
|
||||
matchStandardSitePublisher,
|
||||
matchStandardSitePublisherByUri,
|
||||
@@ -15,15 +18,19 @@ import {
|
||||
isStandardSitePublicationUri,
|
||||
} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
export function StandardSiteMetaRow({
|
||||
type = 'document',
|
||||
preview,
|
||||
view,
|
||||
}: ssTypes.CommonProps &
|
||||
ssTypes.PreviewProps & {
|
||||
type?: 'document' | 'publication'
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const highlightedPublisher = !!matchStandardSitePublisher(view)
|
||||
const didsFromRecords =
|
||||
view.associatedRefs
|
||||
@@ -40,11 +47,7 @@ export function StandardSiteMetaRow({
|
||||
: undefined
|
||||
const articleDomain = toNiceDomain(view.uri)
|
||||
const articlePublisher = matchStandardSitePublisherByUri(view.uri)
|
||||
const domainHandleMatch =
|
||||
authorProfile?.handle &&
|
||||
(articleDomain === authorProfile.handle ||
|
||||
articleDomain.endsWith(`.${authorProfile.handle}`))
|
||||
const DomainIcon = articlePublisher?.Icon
|
||||
const DomainIcon = articlePublisher?.Icon || StandardSite
|
||||
const metaTextStyle = [
|
||||
a.text_xs,
|
||||
a.leading_tight,
|
||||
@@ -53,7 +56,7 @@ export function StandardSiteMetaRow({
|
||||
|
||||
const items: {key: string; node: ReactNode}[] = []
|
||||
|
||||
if (!highlightedPublisher && !domainHandleMatch) {
|
||||
if (!highlightedPublisher) {
|
||||
items.push({
|
||||
key: 'domain',
|
||||
node: (
|
||||
@@ -74,7 +77,25 @@ export function StandardSiteMetaRow({
|
||||
key: 'author',
|
||||
node: (
|
||||
<Text numberOfLines={1} style={[metaTextStyle]}>
|
||||
<Trans>by @{authorProfile.handle}</Trans>
|
||||
<Trans>
|
||||
by{' '}
|
||||
<InlineLinkText
|
||||
label={l`View @${authorProfile.handle}'s profile`}
|
||||
to={makeProfileLink(authorProfile)}
|
||||
style={[
|
||||
metaTextStyle,
|
||||
preview ? a.pointer_events_none : a.pointer_events_auto,
|
||||
]}
|
||||
onPress={e => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
ax.metric('embed:standardSite:authorHandle:press', {
|
||||
handle: authorProfile.handle,
|
||||
})
|
||||
}}>
|
||||
@{authorProfile.handle}
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@ import {Divider} from '#/components/Divider'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
|
||||
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
|
||||
import {StandardSite} from '#/components/icons/community/StandardSite'
|
||||
import {Link} from '#/components/Link'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
|
||||
@@ -127,6 +126,7 @@ export const StandardSiteEmbed = ({
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.rounded_lg,
|
||||
a.overflow_hidden,
|
||||
a.w_full,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
@@ -134,149 +134,126 @@ export const StandardSiteEmbed = ({
|
||||
preview && a.pointer_events_none,
|
||||
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
|
||||
peek
|
||||
shouldProxy
|
||||
to={view.uri}
|
||||
label={view.title || l`Open link to ${niceUrl}`}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={[a.rounded_lg]}
|
||||
style={[a.absolute, a.inset_0, a.z_10]}
|
||||
{...web({
|
||||
onMouseEnter: onInteract,
|
||||
onMouseLeave: onInteractOut,
|
||||
})}
|
||||
onFocus={onInteract}
|
||||
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
|
||||
style={[
|
||||
a.w_full,
|
||||
a.overflow_hidden,
|
||||
// The container can't clip (overflow_hidden there breaks the peek
|
||||
// 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,
|
||||
a.pb_xs,
|
||||
a.px_md,
|
||||
{gap: 3},
|
||||
isStandard && [{gap: 5}, a.pb_sm],
|
||||
]}>
|
||||
{imageUri ? (
|
||||
<Image
|
||||
style={[a.aspect_card]}
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<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
|
||||
emoji
|
||||
numberOfLines={view.thumb ? 2 : 4}
|
||||
style={[a.text_sm, a.leading_snug]}>
|
||||
{view.description}
|
||||
</Text>
|
||||
) : 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,
|
||||
]}>
|
||||
{isStandard && (view.createdAt || view.readingTime) && (
|
||||
<View
|
||||
style={[
|
||||
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 ? (
|
||||
style={[a.flex_row, a.align_center, a.gap_md, {paddingTop: 2}]}>
|
||||
{view.createdAt && (
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={view.thumb ? 2 : 4}
|
||||
style={[a.text_sm, a.leading_snug]}>
|
||||
{view.description}
|
||||
</Text>
|
||||
) : undefined}
|
||||
|
||||
{isStandard && (view.createdAt || view.readingTime) && (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_md,
|
||||
{paddingTop: 2},
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{view.createdAt && (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{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>
|
||||
)}
|
||||
{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.source && (
|
||||
<View style={[a.px_md]}>
|
||||
<Divider />
|
||||
<View style={[a.py_sm]}>
|
||||
<StandardSiteMetaRow preview={preview} 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>
|
||||
)}
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
{view.source && (
|
||||
<View>
|
||||
<View style={[a.z_20]}>
|
||||
<Divider />
|
||||
<PublicationFooter
|
||||
preview={preview}
|
||||
@@ -558,9 +535,8 @@ function PublicationIcon({
|
||||
interacted?: boolean
|
||||
themeColors: ssTypes.ThemeColors
|
||||
}) {
|
||||
const t = useTheme()
|
||||
if (!view.source) return null
|
||||
const icon = view.source?.icon ? (
|
||||
return view.source?.icon ? (
|
||||
<View>
|
||||
<UserAvatar
|
||||
noBorder
|
||||
@@ -591,29 +567,6 @@ function PublicationIcon({
|
||||
<MediaInsetBorder opaque style={[a.rounded_sm]} />
|
||||
</View>
|
||||
)
|
||||
return (
|
||||
<View style={[a.relative]}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
a.z_10,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
t.atoms.bg,
|
||||
{
|
||||
width: 16,
|
||||
height: 16,
|
||||
top: -6,
|
||||
left: -6,
|
||||
},
|
||||
]}>
|
||||
<StandardSite size="xs" fill={t.atoms.text_contrast_medium.color} />
|
||||
<MediaInsetBorder />
|
||||
</View>
|
||||
{icon}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicationFooter({
|
||||
@@ -651,14 +604,6 @@ export function PublicationFooter({
|
||||
a.justify_between,
|
||||
a.p_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],
|
||||
interactedOuter && t.atoms.bg_contrast_25,
|
||||
preview && a.pointer_events_none,
|
||||
|
||||
@@ -108,15 +108,10 @@ export function useActiveVideoWeb() {
|
||||
|
||||
return {
|
||||
active: activeViewId === id,
|
||||
setActive: useCallback(() => {
|
||||
setActive: () => {
|
||||
setActiveView(id)
|
||||
}, [setActiveView, id]),
|
||||
},
|
||||
currentActiveView: activeViewId,
|
||||
sendPosition: useCallback(
|
||||
(y: number) => {
|
||||
sendViewPosition(id, y)
|
||||
},
|
||||
[sendViewPosition, id],
|
||||
),
|
||||
sendPosition: (y: number) => sendViewPosition(id, y),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,6 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {BlockDialog} from '#/components/moderation/BlockDialog'
|
||||
import {
|
||||
ReportDialog,
|
||||
useReportDialogControl,
|
||||
@@ -551,17 +550,9 @@ let PostMenuItems = ({
|
||||
) : (
|
||||
<Menu.Item
|
||||
testID="postDropdownTranslateBtn"
|
||||
label={
|
||||
forceGoogleTranslate
|
||||
? l`Open in Google Translate`
|
||||
: l`Translate`
|
||||
}
|
||||
label={l`Translate`}
|
||||
onPress={onPressTranslate}>
|
||||
<Menu.ItemText>
|
||||
{forceGoogleTranslate
|
||||
? l`Open in Google Translate`
|
||||
: l`Translate`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Translate`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -854,10 +845,13 @@ let PostMenuItems = ({
|
||||
onConfirm={() => void onToggleReplyVisibility()}
|
||||
confirmButtonCta={l`Yes, hide`}
|
||||
/>
|
||||
<BlockDialog
|
||||
<Prompt.Basic
|
||||
control={blockPromptControl}
|
||||
profile={postAuthor}
|
||||
onBlock={onBlockAuthor}
|
||||
title={l`Block Account?`}
|
||||
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
|
||||
onConfirm={() => void onBlockAuthor()}
|
||||
confirmButtonCta={l`Block`}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -152,7 +152,11 @@ function RecentChatItem({
|
||||
a.align_center,
|
||||
]}>
|
||||
{convo.kind === 'group' ? (
|
||||
<AvatarBubbles profiles={convo.members} size={WIDTH - 8} />
|
||||
<AvatarBubbles
|
||||
profiles={convo.members}
|
||||
size={WIDTH - 8}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar
|
||||
avatar={primaryProfile.avatar}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {Suspense, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import type ViewShot from 'react-native-view-shot'
|
||||
import {requestPermissionsAsync, saveToLibraryAsync} from 'expo-media-library'
|
||||
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||
import {createAssetAsync} from 'expo-media-library'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -59,9 +60,7 @@ export function QrCodeDialog({
|
||||
const onSavePress = async () => {
|
||||
ref.current?.capture?.().then(async (uri: string) => {
|
||||
if (IS_NATIVE) {
|
||||
// Write-only permission - saving the QR image does not require read
|
||||
// access to the user's photo library.
|
||||
const res = await requestPermissionsAsync(true)
|
||||
const res = await requestMediaLibraryPermissionsAsync()
|
||||
|
||||
if (!res.granted) {
|
||||
Toast.show(
|
||||
@@ -74,9 +73,7 @@ export function QrCodeDialog({
|
||||
|
||||
// Incase of a FS failure, don't crash the app
|
||||
try {
|
||||
// saveToLibraryAsync writes without reading the asset back, so it
|
||||
// works with the add-only permission on iOS (APP-2374)
|
||||
await saveToLibraryAsync(`file://${uri}`)
|
||||
await createAssetAsync(`file://${uri}`)
|
||||
} catch (e: unknown) {
|
||||
Toast.show(_(msg`An error occurred while saving the QR code!`), {
|
||||
type: 'error',
|
||||
|
||||
@@ -514,7 +514,11 @@ function ExistingChatCard({
|
||||
]}>
|
||||
<ProfileCard.Header>
|
||||
{convo.kind === 'group' ? (
|
||||
<AvatarBubbles profiles={convo.members} size={40} />
|
||||
<AvatarBubbles
|
||||
profiles={convo.members}
|
||||
size={40}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
) : (
|
||||
<ProfileCard.Avatar
|
||||
profile={convo.primaryMember}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Image} from 'expo-image'
|
||||
import {type ThemeName} from '@bsky.app/alf'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {
|
||||
atoms as a,
|
||||
native,
|
||||
platform,
|
||||
type TextStyleProp,
|
||||
useTheme,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group'
|
||||
import {Shield_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
||||
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_E2E, IS_WEB} from '#/env'
|
||||
import {createIsEnabledCheck, isExistingUserAsOf} from './utils'
|
||||
|
||||
// Gate: only show to existing users (created before 2026-06-11), not E2E
|
||||
export const enabled = createIsEnabledCheck(props => {
|
||||
return (
|
||||
!IS_E2E &&
|
||||
isExistingUserAsOf(
|
||||
'2026-06-11T00:00:00.000Z',
|
||||
props.currentProfile.createdAt,
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
function getHero(theme: ThemeName) {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return require('../../../../assets/images/groupchats_announcement_light.webp')
|
||||
case 'dark':
|
||||
return require('../../../../assets/images/groupchats_announcement_dark.webp')
|
||||
case 'dim':
|
||||
return require('../../../../assets/images/groupchats_announcement_dim.webp')
|
||||
}
|
||||
}
|
||||
|
||||
export function GroupChatsAnnouncement() {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const control = Dialog.useDialogControl()
|
||||
const {bottom} = useSafeAreaInsets()
|
||||
|
||||
// Measure the footer so the scrollable content can pad itself out from
|
||||
// underneath it (the footer is absolutely positioned).
|
||||
const [footerHeight, setFooterHeight] = useState(
|
||||
platform({
|
||||
native: 124 + bottom,
|
||||
web: 128,
|
||||
default: 0,
|
||||
}),
|
||||
)
|
||||
|
||||
Dialog.useAutoOpen(control)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
}, [nuxDialogs])
|
||||
|
||||
const onPressStartGroupChat = useCallback(() => {
|
||||
control.close(() => {
|
||||
if (IS_WEB) {
|
||||
navigation.navigate('Messages', {pushToNewGroupChat: true})
|
||||
} else {
|
||||
// On native, Messages is nested inside MessagesTab.
|
||||
// @ts-expect-error nested navigators aren't typed -sfn
|
||||
navigation.navigate('MessagesTab', {
|
||||
screen: 'Messages',
|
||||
params: {pushToNewGroupChat: true},
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [control, navigation])
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
showsVerticalScrollIndicator={false}
|
||||
label={l`Introducing group chats`}
|
||||
// Fill the full-height sheet so the absolute footer pins to the bottom.
|
||||
style={[
|
||||
native(a.h_full),
|
||||
web([{maxWidth: 440}, a.overflow_hidden, {borderRadius: 32}]),
|
||||
]}
|
||||
contentContainerStyle={[
|
||||
native(a.p_0),
|
||||
web(a.p_md),
|
||||
{paddingBottom: footerHeight},
|
||||
]}
|
||||
footer={
|
||||
<Dialog.FlatListFooter
|
||||
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
|
||||
border={false}>
|
||||
<View
|
||||
style={[
|
||||
a.gap_md,
|
||||
native(a.px_lg),
|
||||
web([a.px_lg, a.pb_2xl, a.pt_xl]),
|
||||
]}>
|
||||
<Button
|
||||
label={l`Got it`}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Got it</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={l`Start a group chat`}
|
||||
size="large"
|
||||
color="secondary"
|
||||
onPress={onPressStartGroupChat}>
|
||||
<ButtonText>
|
||||
<Trans>Start a group chat</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Dialog.FlatListFooter>
|
||||
}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
platform({
|
||||
web: [a.pt_xl, a.px_4xl],
|
||||
native: [a.pt_xl, a.pb_md, a.px_sm],
|
||||
}),
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={getHero(t.name)}
|
||||
style={[a.w_full, {aspectRatio: 343 / 230}]}
|
||||
alt={l({
|
||||
message: `Four message bubbles representing a group chat. First message: "Did you hear the news? Bluesky has group chats now!" Second message: "omg, no way" Third message: "Wow, 50 people in one chat!" Fourth message: "You can send invite links too!"`,
|
||||
comment:
|
||||
'This is alt text for a marketing image which transcribes English text that appears in the image',
|
||||
})}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.px_xl, a.pt_2xl, a.gap_2xl]}>
|
||||
<View style={[a.align_center, a.gap_sm]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, {left: -6}]}>
|
||||
<SparkleIcon fill={t.palette.primary_500} size="sm" />
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_medium,
|
||||
{color: t.palette.primary_500},
|
||||
]}>
|
||||
<Trans>New</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_center,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
{fontSize: IS_WEB ? 32 : 36},
|
||||
]}>
|
||||
<Trans>Group Chats</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.text_center]}>
|
||||
<Trans>Take the conversation private.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_xl, a.pt_sm, a.pb_md]}>
|
||||
<Feature
|
||||
icon={ChainLinkIcon}
|
||||
titleText={<Trans>Add people with a link</Trans>}
|
||||
descriptionText={
|
||||
<Trans>Post it to Bluesky or share anywhere.</Trans>
|
||||
}
|
||||
/>
|
||||
<Feature
|
||||
icon={GroupIcon}
|
||||
titleText={<Trans>Up to 50 people</Trans>}
|
||||
descriptionText={
|
||||
<Trans>Bring up to 50 friends together in one chat.</Trans>
|
||||
}
|
||||
/>
|
||||
<Feature
|
||||
icon={ShieldIcon}
|
||||
titleText={<Trans>You’re in control</Trans>}
|
||||
descriptionText={
|
||||
<Trans>
|
||||
Mute, leave, or remove people anytime. It’s your chat.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Feature({
|
||||
icon: Icon,
|
||||
titleText,
|
||||
descriptionText,
|
||||
style,
|
||||
}: {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
titleText: React.ReactNode
|
||||
descriptionText: React.ReactNode
|
||||
} & TextStyleProp) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, style]}>
|
||||
<Icon size="md" style={[t.atoms.text]} />
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text style={[a.text_md, a.font_semi_bold]}>{titleText}</Text>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{descriptionText}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useCallOnce} from '#/lib/once'
|
||||
import {Nux} from '#/state/queries/nuxs'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {themes} from '#/alf/themes'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
||||
import {createIsEnabledCheck} from './utils'
|
||||
|
||||
export const enabled = createIsEnabledCheck(() => {
|
||||
// Native-only feature (matches the rest of the invite-friends scope).
|
||||
// Skipped in E2E to keep tests deterministic.
|
||||
return !IS_E2E && IS_NATIVE
|
||||
})
|
||||
|
||||
export function InviteFriendsAnnouncement() {
|
||||
const {activeNux} = useNuxDialogContext()
|
||||
/*
|
||||
* The invite dialog's control lives here (outside the activeNux
|
||||
* conditional) so the dialog survives the announcement being dismissed
|
||||
* during the "Try it" handoff - the native bottom sheet cannot hand off to
|
||||
* a second sheet that unmounts along with the first.
|
||||
*/
|
||||
const inviteFriendsControl = Dialog.useDialogControl()
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeNux === Nux.InviteFriendsAnnouncement && (
|
||||
<Announcement inviteFriendsControl={inviteFriendsControl} />
|
||||
)}
|
||||
<InviteFriendsDialog control={inviteFriendsControl} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Announcement({
|
||||
inviteFriendsControl,
|
||||
}: {
|
||||
inviteFriendsControl: Dialog.DialogControlProps
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
Dialog.useAutoOpen(control)
|
||||
|
||||
useCallOnce(() => {
|
||||
ax.metric('invite:nux:presented', {})
|
||||
})()
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
}, [nuxDialogs])
|
||||
|
||||
const onPressTryIt = useCallback(() => {
|
||||
ax.metric('invite:nux:tryItPressed', {})
|
||||
// Close this announcement (which dismisses the NUX and unmounts this
|
||||
// component), then open the invite dialog. Its control lives in the
|
||||
// parent, which stays mounted, so the dialog survives the dismissal.
|
||||
// Defer the open to the next frame so the announcement's teardown
|
||||
// completes first.
|
||||
control.close(() => {
|
||||
requestAnimationFrame(() => {
|
||||
ax.metric('invite:dialog:open', {logContext: 'NuxAnnouncement'})
|
||||
inviteFriendsControl.open()
|
||||
})
|
||||
})
|
||||
}, [ax, control, inviteFriendsControl])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle fill={t.palette.primary_400} />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Invite your friends`}
|
||||
style={[web({maxWidth: 440})]}
|
||||
contentContainerStyle={[
|
||||
{paddingTop: 0, paddingLeft: 0, paddingRight: 0},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.relative,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||
},
|
||||
]}>
|
||||
{/* The image is the full header background; the tag sits on top of it. */}
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={require('../../../../assets/images/invite_friends_announcement_nux.webp')}
|
||||
style={[a.w_full, {aspectRatio: 754 / 440}]}
|
||||
alt={l`An illustration of the Bluesky app with a paper airplane flying out of it, representing inviting friends`}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.align_center,
|
||||
{top: 0, left: 0, right: 0, paddingTop: IS_WEB ? 24 : 20},
|
||||
]}>
|
||||
{/* Pinned to the light palette: the header illustration has a
|
||||
fixed light background, so the tag must stay dark in any
|
||||
theme to remain legible. */}
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SparkleIcon fill={themes.lightPalette.primary_800} size="sm" />
|
||||
<Text
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
{color: themes.lightPalette.primary_800},
|
||||
]}>
|
||||
<Trans>New Feature</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
|
||||
<View style={[a.gap_sm, a.align_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_3xl,
|
||||
a.leading_tight,
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
{fontSize: IS_WEB ? 28 : 32, maxWidth: 300},
|
||||
]}>
|
||||
<Trans>Invite your friends</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_medium,
|
||||
{maxWidth: 340},
|
||||
]}>
|
||||
<Trans>
|
||||
You can now invite friends with a link or QR code. Share the
|
||||
link anywhere, or let them scan the code to join you on
|
||||
Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!IS_WEB && (
|
||||
<View style={[a.w_full, a.gap_sm]}>
|
||||
<Button
|
||||
label={l`Try it`}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={onPressTryIt}
|
||||
style={[a.w_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Try it</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={l`Cancel`}
|
||||
size="large"
|
||||
color="secondary"
|
||||
onPress={() => control.close()}
|
||||
style={[a.w_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -19,13 +19,9 @@ import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {useOnboardingState} from '#/state/shell'
|
||||
import {
|
||||
enabled as isGroupChatsAnnouncementEnabled,
|
||||
GroupChatsAnnouncement,
|
||||
} from '#/components/dialogs/nuxs/GroupChatsAnnouncement'
|
||||
import {
|
||||
enabled as isInviteFriendsAnnouncementEnabled,
|
||||
InviteFriendsAnnouncement,
|
||||
} from '#/components/dialogs/nuxs/InviteFriendsAnnouncement'
|
||||
DraftsAnnouncement,
|
||||
enabled as isDraftsAnnouncementEnabled,
|
||||
} from '#/components/dialogs/nuxs/DraftsAnnouncement'
|
||||
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
||||
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
@@ -41,12 +37,8 @@ const queuedNuxs: {
|
||||
enabled?: (props: EnabledCheckProps) => boolean
|
||||
}[] = [
|
||||
{
|
||||
id: Nux.GroupChatsAnnouncement,
|
||||
enabled: isGroupChatsAnnouncementEnabled,
|
||||
},
|
||||
{
|
||||
id: Nux.InviteFriendsAnnouncement,
|
||||
enabled: isInviteFriendsAnnouncementEnabled,
|
||||
id: Nux.DraftsAnnouncement,
|
||||
enabled: isDraftsAnnouncementEnabled,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -194,13 +186,7 @@ function Inner({
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||
{activeNux === Nux.GroupChatsAnnouncement && <GroupChatsAnnouncement />}
|
||||
{/*
|
||||
Mounted unconditionally: it gates the announcement on `activeNux`
|
||||
internally, so it can keep the invite-friends dialog mounted across
|
||||
the announcement's dismissal during the "Try it" handoff.
|
||||
*/}
|
||||
<InviteFriendsAnnouncement />
|
||||
{activeNux === Nux.DraftsAnnouncement && <DraftsAnnouncement />}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import {IS_DEV} from '#/env'
|
||||
import {simpleAreDatesEqual} from '#/lib/strings/time'
|
||||
import {device} from '#/storage'
|
||||
|
||||
const PROD_SNOOZE_SECONDS = 3 * 60 * 60 // 3 hours
|
||||
const SNOOZE_SECONDS = IS_DEV ? 10 : PROD_SNOOZE_SECONDS
|
||||
|
||||
export function snooze() {
|
||||
device.set(['lastNuxDialog'], new Date().toISOString())
|
||||
}
|
||||
@@ -17,5 +14,9 @@ export function isSnoozed() {
|
||||
if (!lastNuxDialog) return false
|
||||
const last = new Date(lastNuxDialog)
|
||||
const now = new Date()
|
||||
return now.getTime() - last.getTime() < SNOOZE_SECONDS * 1000
|
||||
// already snoozed today
|
||||
if (simpleAreDatesEqual(last, now)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export function ActionsWrapper({
|
||||
children,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
hasReactions?: boolean
|
||||
isFromSelf: boolean
|
||||
senderProfile?: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts | undefined
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {useCallback, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -19,12 +17,14 @@ import {canReact, hasReachedReactionLimit} from './util'
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
hasReactions,
|
||||
isFromSelf,
|
||||
senderProfile,
|
||||
moderationOpts,
|
||||
children,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
hasReactions?: boolean
|
||||
isFromSelf: boolean
|
||||
senderProfile?: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts | undefined
|
||||
@@ -72,18 +72,7 @@ export function ActionsWrapper({
|
||||
.removeReaction(message.id, emoji)
|
||||
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
|
||||
} else {
|
||||
if (hasReachedReactionLimit(message, currentAccount?.did)) {
|
||||
Toast.show(
|
||||
l`You cannot add more than ${plural(EMOJI_REACTION_LIMIT, {
|
||||
one: '# emoji reaction',
|
||||
other: '# emoji reactions',
|
||||
})}`,
|
||||
{
|
||||
type: 'info',
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
if (hasReachedReactionLimit(message, currentAccount?.did)) return
|
||||
convo.addReaction(message.id, emoji).catch(() =>
|
||||
Toast.show(l`Failed to add emoji reaction`, {
|
||||
type: 'error',
|
||||
@@ -111,6 +100,7 @@ export function ActionsWrapper({
|
||||
isFromSelf
|
||||
? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse]
|
||||
: [a.ml_xs, {marginRight: 'auto'}],
|
||||
hasReactions ? [a.mb_2xl] : undefined,
|
||||
]}>
|
||||
{reactionsAvailable && (
|
||||
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
|
||||
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -66,7 +67,6 @@ type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | LoadingItem
|
||||
export type State = {
|
||||
groupChatDids: string[]
|
||||
groupChatProfiles: bsky.profile.AnyProfileView[]
|
||||
searchText: string
|
||||
}
|
||||
|
||||
export type Action =
|
||||
@@ -80,10 +80,6 @@ export type Action =
|
||||
groupChatDids: string[]
|
||||
groupChatProfiles: bsky.profile.AnyProfileView[]
|
||||
}
|
||||
| {
|
||||
type: 'setSearchText'
|
||||
searchText: string
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
@@ -92,7 +88,6 @@ function reducer(state: State, action: Action): State {
|
||||
...state,
|
||||
groupChatDids: action.groupChatDids,
|
||||
groupChatProfiles: action.groupChatProfiles,
|
||||
searchText: '',
|
||||
}
|
||||
}
|
||||
case 'removeDids': {
|
||||
@@ -102,12 +97,6 @@ function reducer(state: State, action: Action): State {
|
||||
groupChatProfiles: action.groupChatProfiles,
|
||||
}
|
||||
}
|
||||
case 'setSearchText': {
|
||||
return {
|
||||
...state,
|
||||
searchText: action.searchText,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,19 +121,11 @@ export function AddMembersFlow({
|
||||
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
const [footerHeight, setFooterHeight] = useState(0)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
|
||||
const [{groupChatDids, groupChatProfiles, searchText}, dispatch] = useReducer(
|
||||
reducer,
|
||||
{
|
||||
groupChatDids: [],
|
||||
groupChatProfiles: [],
|
||||
searchText: '',
|
||||
},
|
||||
)
|
||||
|
||||
const {
|
||||
data: autocompleteResults,
|
||||
isError,
|
||||
@@ -161,12 +142,19 @@ export function AddMembersFlow({
|
||||
[memberListData],
|
||||
)
|
||||
|
||||
const {data: chatStatus} = useChatActorStatusQuery()
|
||||
const groupMemberLimit = chatStatus?.groupMemberLimit
|
||||
// The existing members (including the viewer) already occupy slots, so the
|
||||
// number of people that can still be added is whatever's left.
|
||||
const remainingSlots = Math.max(
|
||||
0,
|
||||
convo.details.memberLimit - memberListData.length,
|
||||
)
|
||||
const remainingSlots =
|
||||
groupMemberLimit !== undefined
|
||||
? Math.max(0, groupMemberLimit - memberListData.length)
|
||||
: undefined
|
||||
|
||||
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
|
||||
groupChatDids: [],
|
||||
groupChatProfiles: [],
|
||||
})
|
||||
|
||||
const onRemoveDid = useCallback(
|
||||
(did: string) => {
|
||||
@@ -221,7 +209,6 @@ export function AddMembersFlow({
|
||||
if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
if (!canBeAddedToGroup(profile)) continue
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
@@ -229,6 +216,12 @@ export function AddMembersFlow({
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items.sort(item => {
|
||||
return item.type === 'profile' && canBeAddedToGroup(item.profile)
|
||||
? -1
|
||||
: 1
|
||||
})
|
||||
} else {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
_items.push({type: 'placeholder', key: i + ''})
|
||||
@@ -417,7 +410,7 @@ export function AddMembersFlow({
|
||||
inputRef={inputRef}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
dispatch({type: 'setSearchText', searchText: text})
|
||||
setSearchText(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
onEscape={control.close}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {View} from 'react-native'
|
||||
import {ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {Plural, Trans} from '@lingui/react/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
@@ -7,7 +6,7 @@ import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {SimpleInlineLinkText} from '#/components/Link'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useChatInvite} from './Context'
|
||||
@@ -21,7 +20,7 @@ export function Card({size}: {size: 'large' | 'small'}) {
|
||||
const t = useTheme()
|
||||
const {preview, hasFixedHeight} = useChatInvite()
|
||||
|
||||
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) return null
|
||||
if (!preview) return null
|
||||
|
||||
const ownerDisplayName = createSanitizedDisplayName(preview.owner)
|
||||
const ownerHandle = sanitizeHandle(preview.owner.handle, '@')
|
||||
@@ -29,12 +28,7 @@ export function Card({size}: {size: 'large' | 'small'}) {
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.align_center]}>
|
||||
<AvatarBubbles
|
||||
size={56}
|
||||
self
|
||||
profiles={avatarProfiles}
|
||||
count={preview.memberCount}
|
||||
/>
|
||||
<AvatarBubbles size={56} self profiles={avatarProfiles} />
|
||||
<View style={[a.flex_1, size === 'large' ? a.gap_2xs : a.gap_xs]}>
|
||||
<Text
|
||||
emoji
|
||||
@@ -62,7 +56,7 @@ export function Card({size}: {size: 'large' | 'small'}) {
|
||||
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
|
||||
{preview.memberCount}/{preview.memberLimit}{' '}
|
||||
<Plural
|
||||
value={preview.memberLimit}
|
||||
value={preview.memberCount}
|
||||
one="member"
|
||||
other="members"
|
||||
/>
|
||||
@@ -83,12 +77,12 @@ export function Card({size}: {size: 'large' | 'small'}) {
|
||||
allowFontScaling={!hasFixedHeight}>
|
||||
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
|
||||
By{' '}
|
||||
<InlineLinkText
|
||||
<SimpleInlineLinkText
|
||||
to={makeProfileLink(preview.owner)}
|
||||
label={ownerDisplayName}
|
||||
style={[a.font_medium, t.atoms.text]}>
|
||||
{ownerDisplayName}
|
||||
</InlineLinkText>
|
||||
</SimpleInlineLinkText>
|
||||
</Trans>
|
||||
</Text>
|
||||
<ProfileBadges
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {createContext, useContext} from 'react'
|
||||
import {type ChatBskyGroupDefs} from '@atproto/api'
|
||||
|
||||
import {type ChatInvitePreview} from '#/state/queries/join-links'
|
||||
import {type ButtonColor} from '#/components/Button'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
|
||||
@@ -22,21 +22,11 @@ export type ChatInviteAction = {
|
||||
side: 'left' | 'right'
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved state of a chat invite:
|
||||
* - `loading`: the preview is still being fetched.
|
||||
* - `error`: the fetch failed (e.g. network). Surfaces may want to fall back to
|
||||
* a plain link rather than show a chat-specific error.
|
||||
* - `unavailable`: the preview resolved but the link is disabled, invalid, or an
|
||||
* unrecognized variant - there's nothing to join.
|
||||
* - `available`: a usable `JoinLinkPreviewView` is present.
|
||||
*/
|
||||
export type ChatInviteStatus = 'loading' | 'error' | 'unavailable' | 'available'
|
||||
|
||||
export type ChatInviteContextValue = {
|
||||
code: string
|
||||
status: ChatInviteStatus
|
||||
preview: ChatInvitePreview | undefined
|
||||
loading: boolean
|
||||
error: boolean
|
||||
preview: ChatBskyGroupDefs.JoinLinkPreviewView | undefined
|
||||
/**
|
||||
* The derived action descriptor. Undefined while loading or when there's no
|
||||
* preview to act on.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
|
||||
/**
|
||||
* Loading state for a chat invite: a centered spinner. The outer container
|
||||
* (height, border, etc.) varies per surface, so pass it via `style`.
|
||||
*/
|
||||
export function Loading({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.align_center, a.justify_center, style]}>
|
||||
<Loader size="md" fill={t.atoms.text.color} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import {setStringAsync} from 'expo-clipboard'
|
||||
import {ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {type ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {
|
||||
type ChatInvitePreview,
|
||||
useJoinLinkPreviewsQuery,
|
||||
} from '#/state/queries/join-links'
|
||||
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type ButtonColor} from '#/components/Button'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||||
@@ -16,13 +13,10 @@ import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/
|
||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {
|
||||
type ChatInviteAction,
|
||||
ChatInviteProvider,
|
||||
type ChatInviteStatus,
|
||||
} from './Context'
|
||||
import {type ChatInviteAction, ChatInviteProvider} from './Context'
|
||||
|
||||
/**
|
||||
* Headless data + state owner for a chat invite. Fetches the join link preview
|
||||
@@ -40,7 +34,7 @@ export function Root({
|
||||
children,
|
||||
}: {
|
||||
code: string
|
||||
initialPreview?: ChatInvitePreview
|
||||
initialPreview?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||
/**
|
||||
* The convo this invite is being viewed within, if any. When the invite
|
||||
* links to the same chat, the action becomes "Copy link" instead of
|
||||
@@ -65,23 +59,12 @@ export function Root({
|
||||
})
|
||||
|
||||
const preview = data?.joinLinkPreviews[0]
|
||||
|
||||
let status: ChatInviteStatus
|
||||
if (isPending && !preview) {
|
||||
status = 'loading'
|
||||
} else if (error) {
|
||||
status = 'error'
|
||||
} else if (ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
|
||||
status = 'available'
|
||||
} else {
|
||||
// Resolved to a disabled/invalid/unrecognized preview - nothing to join.
|
||||
status = 'unavailable'
|
||||
}
|
||||
const loading = isPending && !preview
|
||||
|
||||
let action: ChatInviteAction | undefined
|
||||
if (ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
|
||||
if (preview) {
|
||||
const convoId = preview.convo?.id
|
||||
const isFollowing = preview.owner.viewer?.followedBy ?? false
|
||||
const isFollowing = preview.owner.viewer?.following ?? false
|
||||
const hasRequested = !convoId && preview.viewer?.requestedAt != null
|
||||
|
||||
if (convoId && convoId === currentConvoId) {
|
||||
@@ -116,7 +99,12 @@ export function Root({
|
||||
let icon: React.ComponentType<SVGIconProps> = JoinIcon
|
||||
let label = preview.requireApproval ? l`Request to join` : l`Join`
|
||||
let color: ButtonColor = 'primary'
|
||||
if (preview.memberCount >= preview.memberLimit) {
|
||||
if (preview.enabledStatus !== 'enabled') {
|
||||
canJoin = false
|
||||
icon = WarningIcon
|
||||
label = l`Chat invite link no longer available`
|
||||
color = 'secondary'
|
||||
} else if (preview.memberCount >= preview.memberLimit) {
|
||||
canJoin = false
|
||||
icon = HandIcon
|
||||
label = l`This chat is full`
|
||||
@@ -150,7 +138,8 @@ export function Root({
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatInviteProvider value={{code, status, preview, action, hasFixedHeight}}>
|
||||
<ChatInviteProvider
|
||||
value={{code, loading, error: !!error, preview, action, hasFixedHeight}}>
|
||||
{children}
|
||||
</ChatInviteProvider>
|
||||
)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useChatInvite} from './Context'
|
||||
|
||||
/**
|
||||
* "No longer available" state for a chat invite, shown when the link is
|
||||
* disabled, invalid, or otherwise can't be resolved. The outer container
|
||||
* (height, border, etc.) varies per surface, so pass it via `style`.
|
||||
*/
|
||||
export function Unavailable({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
const t = useTheme()
|
||||
const {hasFixedHeight} = useChatInvite()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, a.justify_center, a.gap_xs, style]}>
|
||||
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
|
||||
<Text
|
||||
style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}
|
||||
allowFontScaling={!hasFixedHeight}>
|
||||
<Trans>Chat invite link no longer available</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,7 @@ export {Card} from './Card'
|
||||
export {
|
||||
type ChatInviteAction,
|
||||
type ChatInviteContextValue,
|
||||
type ChatInviteStatus,
|
||||
useChatInvite,
|
||||
} from './Context'
|
||||
export {JoinButton} from './JoinButton'
|
||||
export {Loading} from './Loading'
|
||||
export {Root} from './Root'
|
||||
export {Unavailable} from './Unavailable'
|
||||
|
||||
@@ -36,7 +36,6 @@ import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Ti
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {ChatProfileTabs} from './ChatProfileTabs'
|
||||
@@ -99,7 +98,6 @@ export type State = {
|
||||
groupChatDids: string[]
|
||||
groupChatProfiles: bsky.profile.AnyProfileView[]
|
||||
groupName: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
export type Action =
|
||||
@@ -133,10 +131,6 @@ export type Action =
|
||||
type: 'goBackFromGroupName'
|
||||
screenTitle: string
|
||||
}
|
||||
| {
|
||||
type: 'setSearchText'
|
||||
searchText: string
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
@@ -155,7 +149,6 @@ function reducer(state: State, action: Action): State {
|
||||
...state,
|
||||
groupChatDids: action.groupChatDids,
|
||||
groupChatProfiles: action.groupChatProfiles,
|
||||
searchText: '',
|
||||
}
|
||||
}
|
||||
case 'removeDids': {
|
||||
@@ -170,7 +163,6 @@ function reducer(state: State, action: Action): State {
|
||||
...state,
|
||||
chatState: ChatState.GROUP_NAME,
|
||||
screenTitle: action.screenTitle,
|
||||
searchText: '',
|
||||
}
|
||||
}
|
||||
case 'nameGroup': {
|
||||
@@ -187,7 +179,6 @@ function reducer(state: State, action: Action): State {
|
||||
groupChatDids: [],
|
||||
groupChatProfiles: [],
|
||||
groupName: '',
|
||||
searchText: '',
|
||||
}
|
||||
}
|
||||
case 'goBackFromGroupName': {
|
||||
@@ -198,12 +189,6 @@ function reducer(state: State, action: Action): State {
|
||||
groupName: '',
|
||||
}
|
||||
}
|
||||
case 'setSearchText': {
|
||||
return {
|
||||
...state,
|
||||
searchText: action.searchText,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,12 +196,10 @@ export function InitiateChatFlow({
|
||||
title,
|
||||
onSelectChat,
|
||||
onSelectGroupChat,
|
||||
startInGroupChat = false,
|
||||
}: {
|
||||
title: string
|
||||
onSelectChat: (did: string) => void
|
||||
onSelectGroupChat: (dids: string[], groupName: string) => void
|
||||
startInGroupChat?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
@@ -226,7 +209,6 @@ export function InitiateChatFlow({
|
||||
const [footerHeight, setFooterHeight] = useState(0)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
const aa = useAgeAssurance()
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const accountTooNewPromptControl = Dialog.useDialogControl()
|
||||
|
||||
@@ -234,24 +216,7 @@ export function InitiateChatFlow({
|
||||
const canCreateGroups = chatStatus?.canCreateGroups ?? true
|
||||
const groupMemberLimit = chatStatus?.groupMemberLimit
|
||||
|
||||
const [
|
||||
{
|
||||
chatState,
|
||||
screenTitle,
|
||||
groupChatDids,
|
||||
groupChatProfiles,
|
||||
groupName,
|
||||
searchText,
|
||||
},
|
||||
dispatch,
|
||||
] = useReducer(reducer, {
|
||||
chatState: startInGroupChat ? ChatState.NEW_GROUP_CHAT : ChatState.NEW_CHAT,
|
||||
screenTitle: startInGroupChat ? l`New group chat` : title,
|
||||
groupChatDids: [],
|
||||
groupChatProfiles: [],
|
||||
groupName: '',
|
||||
searchText: '',
|
||||
})
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const {
|
||||
data: results,
|
||||
@@ -260,6 +225,17 @@ export function InitiateChatFlow({
|
||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||
|
||||
const [
|
||||
{chatState, screenTitle, groupChatDids, groupChatProfiles, groupName},
|
||||
dispatch,
|
||||
] = useReducer(reducer, {
|
||||
chatState: ChatState.NEW_CHAT,
|
||||
screenTitle: title,
|
||||
groupChatDids: [],
|
||||
groupChatProfiles: [],
|
||||
groupName: '',
|
||||
})
|
||||
|
||||
const newGroupChatTitle = l`New group chat`
|
||||
const groupNameTitle = l`Group name`
|
||||
|
||||
@@ -325,7 +301,6 @@ export function InitiateChatFlow({
|
||||
if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
if (!checker(profile)) continue
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
@@ -333,6 +308,10 @@ export function InitiateChatFlow({
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items = _items.sort(item => {
|
||||
return item.type === 'profile' && checker(item.profile) ? -1 : 1
|
||||
})
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
@@ -351,11 +330,7 @@ export function InitiateChatFlow({
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
chatState === ChatState.NEW_CHAT &&
|
||||
searchText === '' &&
|
||||
!aa.flags.groupChatDisabled
|
||||
) {
|
||||
if (chatState === ChatState.NEW_CHAT && searchText === '') {
|
||||
_items.unshift({type: 'newGroupChat', key: 'newGroupChat'})
|
||||
}
|
||||
|
||||
@@ -369,7 +344,6 @@ export function InitiateChatFlow({
|
||||
results,
|
||||
currentAccount?.did,
|
||||
follows,
|
||||
aa.flags.groupChatDisabled,
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
@@ -384,6 +358,7 @@ export function InitiateChatFlow({
|
||||
case ChatState.NEW_GROUP_CHAT:
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
dispatch({type: 'goBackFromNewGroupChat', screenTitle: title})
|
||||
setSearchText('')
|
||||
break
|
||||
case ChatState.GROUP_NAME:
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
@@ -404,6 +379,7 @@ export function InitiateChatFlow({
|
||||
const handlePressNext = useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
dispatch({type: 'startNameGroup', screenTitle: groupNameTitle})
|
||||
setSearchText('')
|
||||
}, [groupNameTitle])
|
||||
|
||||
const handlePressConfirm = useCallback(() => {
|
||||
@@ -534,6 +510,7 @@ export function InitiateChatFlow({
|
||||
a.relative,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
web(a.pb_lg),
|
||||
]}>
|
||||
{IS_NATIVE ? (
|
||||
<Button
|
||||
@@ -569,7 +546,7 @@ export function InitiateChatFlow({
|
||||
color="secondary"
|
||||
style={[a.absolute, a.z_20, {right: -4}]}
|
||||
onPress={() => control.close()}>
|
||||
<ButtonIcon icon={XIcon} size="md" />
|
||||
<ButtonIcon icon={XIcon} size="lg" />
|
||||
</Button>
|
||||
) : showButton ? (
|
||||
<Button
|
||||
@@ -637,7 +614,7 @@ export function InitiateChatFlow({
|
||||
inputRef={inputRef}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
dispatch({type: 'setSearchText', searchText: text})
|
||||
setSearchText(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
onEscape={control.close}
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
type ModerationOpts,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
||||
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
@@ -20,8 +18,6 @@ import {atoms as a} from '#/alf'
|
||||
import * as ContextMenu from '#/components/ContextMenu'
|
||||
import {type TriggerProps} from '#/components/ContextMenu/types'
|
||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
|
||||
@@ -49,12 +45,11 @@ export let MessageContextMenu = ({
|
||||
const {currentAccount} = useSession()
|
||||
const convo = useConvoActive()
|
||||
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
|
||||
const {setReply} = useMessageReplies()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const translate = useGoogleTranslate()
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
|
||||
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
|
||||
|
||||
const primaryMember = useMaybeProfileShadow(convo.convo.primaryMember)
|
||||
const reactionsAvailable = canReact({
|
||||
@@ -103,18 +98,7 @@ export let MessageContextMenu = ({
|
||||
.removeReaction(message.id, emoji)
|
||||
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
|
||||
} else {
|
||||
if (hasReachedReactionLimit(message, currentAccount?.did)) {
|
||||
Toast.show(
|
||||
l`You cannot add more than ${plural(EMOJI_REACTION_LIMIT, {
|
||||
one: '# emoji reaction',
|
||||
other: '# emoji reactions',
|
||||
})}`,
|
||||
{
|
||||
type: 'info',
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
if (hasReachedReactionLimit(message, currentAccount?.did)) return
|
||||
convo.addReaction(message.id, emoji).catch(() =>
|
||||
Toast.show(l`Failed to add emoji reaction`, {
|
||||
type: 'error',
|
||||
@@ -154,13 +138,6 @@ export let MessageContextMenu = ({
|
||||
timeStyle: 'short',
|
||||
})}`}
|
||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownReplyBtn"
|
||||
label={l`Reply`}
|
||||
onPress={() => setReply(message)}>
|
||||
<ContextMenu.ItemIcon icon={ReplyIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
@@ -182,6 +159,7 @@ export let MessageContextMenu = ({
|
||||
</>
|
||||
)}
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={l`Delete message for me`}
|
||||
onPress={() => openDeleteMessage(message)}>
|
||||
@@ -190,6 +168,7 @@ export let MessageContextMenu = ({
|
||||
</ContextMenu.Item>
|
||||
{!isFromSelf && (
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownReportBtn"
|
||||
label={l`Report message`}
|
||||
onPress={() => openReportMessage(message, senderProfile)}>
|
||||
|
||||
@@ -8,14 +8,10 @@ import {
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withSequence,
|
||||
withTiming,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
@@ -25,7 +21,6 @@ import {
|
||||
type ChatBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
ChatBskyEmbedJoinLink,
|
||||
moderateProfile,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
@@ -34,6 +29,7 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {type Shadow} from '#/state/cache/types'
|
||||
@@ -42,15 +38,12 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
|
||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {useSession} from '#/state/session'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, platform, useTheme, utils} from '#/alf'
|
||||
import {atoms as a, native, platform, useTheme} from '#/alf'
|
||||
import {isOnlyEmoji} from '#/alf/typography'
|
||||
import {Button} from '#/components/Button'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -59,56 +52,32 @@ import {DateDivider} from './DateDivider'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
import {MessageItemInviteEmbed} from './MessageItemInviteEmbed'
|
||||
import {groupReactions} from './ReactionsDialog'
|
||||
import {
|
||||
CLUSTERED_MESSAGE_THRESHOLD_MS,
|
||||
filterBlockedReactions,
|
||||
MESSAGE_GAP_THRESHOLD_MS,
|
||||
} from './util'
|
||||
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
|
||||
|
||||
const AVATAR_SIZE = 28
|
||||
const CLUSTERED_MESSAGE_GAP = 2
|
||||
const BORDER_RADIUS = 20
|
||||
const BORDER_RADIUS = 18
|
||||
const SQUARED_BORDER_RADIUS = 4
|
||||
const DISPLAY_NAME_INSET = 20
|
||||
|
||||
function messageIsReply(
|
||||
message:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null,
|
||||
): boolean {
|
||||
return (
|
||||
ChatBskyConvoDefs.isMessageView(message) &&
|
||||
(ChatBskyConvoDefs.isMessageView(message.replyTo) ||
|
||||
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo))
|
||||
)
|
||||
}
|
||||
const DISPLAY_NAME_INSET = 22
|
||||
|
||||
function isWithinClusterBoundary({
|
||||
isPending,
|
||||
message,
|
||||
adjacentMessage,
|
||||
isFromSameSender,
|
||||
currentSentAt,
|
||||
direction,
|
||||
}: {
|
||||
isPending: boolean
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
adjacentMessage:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
isFromSameSender: boolean
|
||||
currentSentAt: string
|
||||
direction: 'prev' | 'next'
|
||||
}): boolean {
|
||||
// A reply always starts its own cluster, breaking grouping with the message
|
||||
// above it. Looking back, that's a boundary if this message is a reply;
|
||||
// looking forward, it's a boundary if the next message is a reply.
|
||||
if (messageIsReply(direction === 'prev' ? message : adjacentMessage)) {
|
||||
return true
|
||||
}
|
||||
if (!isFromSameSender) return true
|
||||
if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
|
||||
const currentSentAt = message.sentAt
|
||||
const thisDate = new Date(currentSentAt)
|
||||
const adjDate = new Date(adjacentMessage.sentAt)
|
||||
const diff =
|
||||
@@ -152,16 +121,6 @@ let MessageItem = ({
|
||||
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
||||
|
||||
const {openReactions} = useMessageDialogs()
|
||||
const {scrollToMessage, highlightedMessage} = useMessageReplies()
|
||||
|
||||
// `replyTo` comes back hydrated as the referenced message (or a deleted-
|
||||
// message tombstone). Narrow away the open-union fallback so we only render
|
||||
// shapes we understand.
|
||||
const replyTo =
|
||||
ChatBskyConvoDefs.isMessageView(message.replyTo) ||
|
||||
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo)
|
||||
? message.replyTo
|
||||
: undefined
|
||||
|
||||
const isPending = item.type === 'pending-message'
|
||||
|
||||
@@ -184,17 +143,17 @@ let MessageItem = ({
|
||||
|
||||
const isFirstInCluster = isWithinClusterBoundary({
|
||||
isPending,
|
||||
message,
|
||||
adjacentMessage: prevMessage,
|
||||
isFromSameSender: isPrevFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'prev',
|
||||
})
|
||||
|
||||
const isLastInCluster = isWithinClusterBoundary({
|
||||
isPending,
|
||||
message,
|
||||
adjacentMessage: nextMessage,
|
||||
isFromSameSender: isNextFromSameSender,
|
||||
currentSentAt: message.sentAt,
|
||||
direction: 'next',
|
||||
})
|
||||
|
||||
@@ -208,15 +167,9 @@ let MessageItem = ({
|
||||
const isInMiddleOfCluster =
|
||||
isInCluster && !isFirstInCluster && !isLastInCluster
|
||||
|
||||
const visibleReactions = useMemo(
|
||||
() => filterBlockedReactions(message.reactions, relatedProfiles),
|
||||
[message.reactions, relatedProfiles],
|
||||
)
|
||||
|
||||
const hasReactions = visibleReactions.length > 0
|
||||
const hasReactions = message.reactions && message.reactions.length > 0
|
||||
const prevHasReactions =
|
||||
prevIsMessage &&
|
||||
filterBlockedReactions(prevMessage.reactions, relatedProfiles).length > 0
|
||||
prevIsMessage && prevMessage.reactions && prevMessage.reactions.length > 0
|
||||
const isNextEmojiOnly = nextIsMessage && isOnlyEmoji(nextMessage.text)
|
||||
const isPrevEmojiOnly = prevIsMessage && isOnlyEmoji(prevMessage.text)
|
||||
const squaredBottomCorner =
|
||||
@@ -260,26 +213,6 @@ let MessageItem = ({
|
||||
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
|
||||
}, [targetTopRadius, topRadiusSV])
|
||||
|
||||
// Flash the message background when it's been scrolled to (e.g. by tapping a
|
||||
// reply that quotes it), so it's easy to spot. Keyed on the highlight `key`
|
||||
// so re-tapping the same message re-triggers the flash.
|
||||
const highlightSV = useSharedValue(0)
|
||||
const isHighlighted = highlightedMessage?.id === message.id
|
||||
const highlightKey = isHighlighted ? highlightedMessage.key : null
|
||||
useEffect(() => {
|
||||
if (highlightKey === null) return
|
||||
highlightSV.set(
|
||||
withSequence(
|
||||
withTiming(1, {duration: 150}),
|
||||
withDelay(400, withTiming(0, {duration: 450})),
|
||||
),
|
||||
)
|
||||
}, [highlightKey, highlightSV])
|
||||
|
||||
const highlightStyle = useAnimatedStyle(() => ({
|
||||
opacity: highlightSV.get(),
|
||||
}))
|
||||
|
||||
const borderRadiusStyle = useAnimatedStyle(() =>
|
||||
isFromSelf
|
||||
? {
|
||||
@@ -294,23 +227,32 @@ let MessageItem = ({
|
||||
|
||||
const avatar =
|
||||
profile && moderationOpts ? (
|
||||
<PreviewableUserAvatar
|
||||
profile={profile}
|
||||
size={AVATAR_SIZE}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
onBeforePress={() => unstableCacheProfileView(queryClient, profile)}
|
||||
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
|
||||
/>
|
||||
<Link
|
||||
style={[a.rounded_full]}
|
||||
label={l`${createSanitizedDisplayName(profile)}’s avatar`}
|
||||
accessibilityHint={l`Opens this profile`}
|
||||
to={makeProfileLink({
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
})}
|
||||
onPress={() => unstableCacheProfileView(queryClient, profile)}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
size={AVATAR_SIZE}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||
)
|
||||
|
||||
const groupedReactions = useMemo(
|
||||
() => groupReactions(visibleReactions),
|
||||
[visibleReactions],
|
||||
() => groupReactions(message.reactions),
|
||||
[message.reactions],
|
||||
)
|
||||
|
||||
const reactions = visibleReactions
|
||||
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
|
||||
|
||||
const hasSelfReacted = reactions.some(
|
||||
r => r.sender.did === currentAccount?.did,
|
||||
@@ -341,15 +283,12 @@ let MessageItem = ({
|
||||
const appliedReactions = (
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
{hasReactions ? (
|
||||
<Animated.View
|
||||
entering={FadeIn}
|
||||
exiting={FadeOut}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
{top: '100%'},
|
||||
isFromSelf ? [a.right_0] : [a.left_0, isGroupChat && a.ml_sm],
|
||||
a.relative,
|
||||
a.bottom_0,
|
||||
isFromSelf ? [a.align_end] : [a.ml_sm, a.align_start],
|
||||
a.px_sm,
|
||||
a.z_10,
|
||||
]}>
|
||||
<Pressable
|
||||
accessible={true}
|
||||
@@ -361,22 +300,24 @@ let MessageItem = ({
|
||||
a.flex_row,
|
||||
a.gap_2xs,
|
||||
isFromSelf ? a.justify_end : a.justify_start,
|
||||
a.flex_wrap,
|
||||
a.rounded_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.shadow_xs,
|
||||
a.px_sm,
|
||||
hasSelfReacted
|
||||
? {backgroundColor: t.palette.primary_100}
|
||||
: t.atoms.bg_contrast_25,
|
||||
{
|
||||
paddingTop: platform({android: 2, default: 3}),
|
||||
paddingBottom: platform({android: 2, default: 3}),
|
||||
transform: [{translateY: -6}],
|
||||
paddingLeft: 6,
|
||||
paddingRight: 6,
|
||||
transform: [{translateY: -8}],
|
||||
},
|
||||
]}
|
||||
onPress={isGroupChat ? () => openReactions(message) : undefined}>
|
||||
{groupedReactions.slice(0, 10).map(group => (
|
||||
{groupedReactions.map(group => (
|
||||
<Animated.View
|
||||
entering={native(ZoomIn.springify(200).delay(400))}
|
||||
exiting={
|
||||
@@ -397,8 +338,7 @@ let MessageItem = ({
|
||||
</Text>
|
||||
</Animated.View>
|
||||
))}
|
||||
{(groupedReactions.length !== reactions.length ||
|
||||
groupedReactions.length > 10) &&
|
||||
{groupedReactions.length !== reactions.length &&
|
||||
reactions.length > 1 ? (
|
||||
<View style={[a.p_2xs, a.pl_0, a.justify_center]}>
|
||||
<Text
|
||||
@@ -415,7 +355,7 @@ let MessageItem = ({
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
) : null}
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
@@ -426,14 +366,6 @@ let MessageItem = ({
|
||||
web: a.mx_lg,
|
||||
})
|
||||
|
||||
// Negative of `messageInset` so the flash bleeds past the row's horizontal
|
||||
// margin to the screen edges.
|
||||
const flashBleed = platform<number>({
|
||||
android: -a.mx_sm.marginLeft,
|
||||
ios: -a.mx_md.marginLeft,
|
||||
web: -a.mx_lg.marginLeft,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasLargeGapFromPrev && <DateDivider date={message.sentAt} />}
|
||||
@@ -441,42 +373,34 @@ let MessageItem = ({
|
||||
style={[
|
||||
messageInset,
|
||||
isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP},
|
||||
hasReactions && {paddingBottom: 26},
|
||||
]}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
top: -CLUSTERED_MESSAGE_GAP,
|
||||
bottom: -CLUSTERED_MESSAGE_GAP,
|
||||
left: flashBleed,
|
||||
right: flashBleed,
|
||||
backgroundColor: t.palette.primary_100,
|
||||
},
|
||||
highlightStyle,
|
||||
]}
|
||||
/>
|
||||
<View style={[a.relative]}>
|
||||
{showAvatar ? (
|
||||
<View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.bottom_0,
|
||||
a.z_50,
|
||||
hasReactions && {
|
||||
transform: [
|
||||
{
|
||||
translateY: platform({
|
||||
ios: -29,
|
||||
default: -27,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}>
|
||||
{avatar}
|
||||
</View>
|
||||
) : null}
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.flex_grow,
|
||||
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
|
||||
]}>
|
||||
{replyTo ? (
|
||||
<ReplyCaption
|
||||
replyTo={replyTo}
|
||||
isFromSelf={isFromSelf}
|
||||
isGroupChat={isGroupChat}
|
||||
replierDisplayName={displayName}
|
||||
relatedProfiles={relatedProfiles}
|
||||
onPress={() => scrollToMessage(replyTo.id)}
|
||||
/>
|
||||
) : displayName && showDisplayName ? (
|
||||
{displayName && showDisplayName ? (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
@@ -492,94 +416,81 @@ let MessageItem = ({
|
||||
{profile && isBlockedOrBlocking(profile) && isGroupChat ? (
|
||||
<BlockedPlaceholder profile={profile} style={borderRadiusStyle} />
|
||||
) : (
|
||||
<View style={[a.relative]}>
|
||||
<ActionsWrapper
|
||||
isFromSelf={isFromSelf}
|
||||
message={message}
|
||||
senderProfile={profile}
|
||||
moderationOpts={moderationOpts}>
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed
|
||||
embed={message.embed}
|
||||
isFromSelf={isFromSelf}
|
||||
isGroupChat={isGroupChat}
|
||||
squaredBottomCorner={
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
}
|
||||
squaredTopCorner={squaredTopCorner}
|
||||
/>
|
||||
)}
|
||||
{ChatBskyEmbedJoinLink.isView(message.embed) && (
|
||||
<MessageItemInviteEmbed
|
||||
embed={message.embed}
|
||||
isFromSelf={isFromSelf}
|
||||
isGroupChat={isGroupChat}
|
||||
squaredBottomCorner={
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
}
|
||||
squaredTopCorner={squaredTopCorner}
|
||||
/>
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
<Animated.View
|
||||
accessibilityHint={l`Double tap or long press the message to add a reaction`}
|
||||
<ActionsWrapper
|
||||
hasReactions={hasReactions}
|
||||
isFromSelf={isFromSelf}
|
||||
message={message}
|
||||
senderProfile={profile}
|
||||
moderationOpts={moderationOpts}>
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed
|
||||
embed={message.embed}
|
||||
isFromSelf={isFromSelf}
|
||||
isGroupChat={isGroupChat}
|
||||
squaredBottomCorner={squaredBottomCorner || hasEmbedAndText}
|
||||
squaredTopCorner={squaredTopCorner}
|
||||
/>
|
||||
)}
|
||||
{ChatBskyEmbedJoinLink.isView(message.embed) && (
|
||||
<MessageItemInviteEmbed
|
||||
embed={message.embed}
|
||||
isFromSelf={isFromSelf}
|
||||
isGroupChat={isGroupChat}
|
||||
squaredBottomCorner={squaredBottomCorner || hasEmbedAndText}
|
||||
squaredTopCorner={squaredTopCorner}
|
||||
/>
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
<Animated.View
|
||||
accessibilityHint={l`Double tap or long press the message to add a reaction`}
|
||||
style={[
|
||||
!isFromSelf && isGroupChat && a.ml_sm,
|
||||
!isOnlyEmoji(message.text) && [
|
||||
a.rounded_xl,
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
{
|
||||
marginTop: hasEmbedAndText
|
||||
? CLUSTERED_MESSAGE_GAP
|
||||
: 0,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
borderRadiusStyle,
|
||||
],
|
||||
]}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[
|
||||
!isFromSelf && isGroupChat && a.ml_sm,
|
||||
!isOnlyEmoji(message.text) && [
|
||||
a.rounded_xl,
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
{
|
||||
marginTop: hasEmbedAndText
|
||||
? CLUSTERED_MESSAGE_GAP
|
||||
: 0,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
borderRadiusStyle,
|
||||
a.text_md,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
// Emoji-only: add top leading to avoid clipping the
|
||||
// glyph, then pull the bottom up by the same amount so
|
||||
// the glyph bottom-aligns with the avatar instead of
|
||||
// sitting above its line-box baseline.
|
||||
isOnlyEmoji(message.text) && [
|
||||
a.leading_tight,
|
||||
// Visually align bottom of the emoji with the avatar
|
||||
!isFromSelf &&
|
||||
platform({
|
||||
android: {marginTop: a.mt_2xs.marginTop},
|
||||
default: {marginBottom: -a.mb_sm.marginBottom},
|
||||
}),
|
||||
],
|
||||
]}>
|
||||
{replyTo && !isOnlyEmoji(message.text) ? (
|
||||
<ReplyQuote
|
||||
replyTo={replyTo}
|
||||
isFromSelf={isFromSelf}
|
||||
relatedProfiles={relatedProfiles}
|
||||
onPress={() => scrollToMessage(replyTo.id)}
|
||||
/>
|
||||
) : null}
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[
|
||||
a.text_md,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
// Emoji-only: add top leading to avoid clipping the
|
||||
// glyph, then pull the bottom up by the same amount so
|
||||
// the glyph bottom-aligns with the avatar instead of
|
||||
// sitting above its line-box baseline.
|
||||
isOnlyEmoji(message.text) && [
|
||||
a.leading_tight,
|
||||
// Visually align bottom of the emoji with the avatar
|
||||
!isFromSelf &&
|
||||
platform({
|
||||
android: {marginTop: a.mt_2xs.marginTop},
|
||||
default: {marginBottom: -a.mb_sm.marginBottom},
|
||||
}),
|
||||
],
|
||||
]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</ActionsWrapper>
|
||||
]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
{appliedReactions}
|
||||
</View>
|
||||
</ActionsWrapper>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -730,7 +641,7 @@ function BlockedPlaceholder({
|
||||
<Prompt.Action onPress={() => {}} cta={l`Okay`} color="primary" />
|
||||
{profile.viewer?.blocking && !profile.viewer.blockingByList && (
|
||||
<Prompt.Action
|
||||
onPress={() => void queueUnblock()}
|
||||
onPress={() => queueUnblock()}
|
||||
cta={l`Unblock`}
|
||||
color="secondary"
|
||||
/>
|
||||
@@ -741,181 +652,3 @@ function BlockedPlaceholder({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "↪ X replied to Y" caption rendered above a reply message, in place of
|
||||
* the display name. `X` is the person sending the reply (self -> "you"), `Y` is
|
||||
* the original sender. Tapping it scrolls to the original (if loaded).
|
||||
*
|
||||
* Aligns with the sender's display name for others (left), or with the message
|
||||
* bubble for self (right).
|
||||
*/
|
||||
function ReplyCaption({
|
||||
replyTo,
|
||||
isFromSelf,
|
||||
isGroupChat,
|
||||
replierDisplayName,
|
||||
relatedProfiles,
|
||||
onPress,
|
||||
}: {
|
||||
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
|
||||
isFromSelf: boolean
|
||||
isGroupChat: boolean
|
||||
replierDisplayName: string | null
|
||||
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did
|
||||
const originalProfile = relatedProfiles.get(replyTo.sender.did)
|
||||
const originalName = originalSenderIsSelf
|
||||
? null
|
||||
: originalProfile
|
||||
? createSanitizedDisplayName(originalProfile)
|
||||
: null
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={l`Scroll to the message this is replying to`}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_2xs,
|
||||
a.pb_2xs,
|
||||
a.pt_xs,
|
||||
isFromSelf
|
||||
? [a.justify_end, a.pr_md]
|
||||
: [
|
||||
a.justify_start,
|
||||
isGroupChat ? {paddingLeft: DISPLAY_NAME_INSET} : a.pl_md,
|
||||
],
|
||||
]}>
|
||||
<ArrowCornerDownRightIcon
|
||||
size="xs"
|
||||
style={t.atoms.text_contrast_medium}
|
||||
/>
|
||||
<Text
|
||||
style={[a.text_xs, a.flex_shrink, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}
|
||||
emoji>
|
||||
{isFromSelf ? (
|
||||
originalSenderIsSelf ? (
|
||||
<Trans>You replied to yourself</Trans>
|
||||
) : originalName ? (
|
||||
<Trans>You replied to {originalName}</Trans>
|
||||
) : (
|
||||
<Trans>You replied</Trans>
|
||||
)
|
||||
) : originalSenderIsSelf ? (
|
||||
<Trans>{replierDisplayName} replied to you</Trans>
|
||||
) : originalName ? (
|
||||
<Trans>
|
||||
{replierDisplayName} replied to {originalName}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>{replierDisplayName} replied</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The nested quote of the original message, rendered at the top of a reply
|
||||
* bubble. Tapping it scrolls to the original (if loaded).
|
||||
*/
|
||||
function ReplyQuote({
|
||||
replyTo,
|
||||
isFromSelf,
|
||||
relatedProfiles,
|
||||
onPress,
|
||||
}: {
|
||||
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
|
||||
isFromSelf: boolean
|
||||
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const senderProfile = useMaybeProfileShadow(
|
||||
relatedProfiles.get(replyTo.sender.did),
|
||||
)
|
||||
// Hide the quoted content if we block, or are blocked by, the original
|
||||
// sender - mirroring how the message bubble itself is hidden.
|
||||
const isBlocked = senderProfile ? isBlockedOrBlocking(senderProfile) : false
|
||||
const senderName =
|
||||
senderProfile && !isBlocked
|
||||
? createSanitizedDisplayName(senderProfile)
|
||||
: null
|
||||
|
||||
const tintColor = isFromSelf ? t.palette.white : t.atoms.text.color
|
||||
const subtleColor = isFromSelf
|
||||
? t.palette.white
|
||||
: t.atoms.text_contrast_high.color
|
||||
const borderColor = isFromSelf
|
||||
? utils.alpha(t.palette.white, 0.5)
|
||||
: t.atoms.border_contrast_high.borderColor
|
||||
|
||||
let text: string
|
||||
let subtle = false
|
||||
if (isBlocked) {
|
||||
text = l`Blocked message hidden`
|
||||
subtle = true
|
||||
} else if (ChatBskyConvoDefs.isMessageView(replyTo)) {
|
||||
text = replyTo.text
|
||||
if (!text.trim()) {
|
||||
subtle = true
|
||||
if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) {
|
||||
text = l`(chat invite link)`
|
||||
} else if (AppBskyEmbedRecord.isView(replyTo.embed)) {
|
||||
text = l`(contains embedded content)`
|
||||
} else {
|
||||
text = l`No text`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
text = l`Deleted message`
|
||||
subtle = true
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={
|
||||
senderName
|
||||
? l`Replied-to message from ${senderName}, tap to scroll to it`
|
||||
: l`Replied-to message, tap to scroll to it`
|
||||
}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
a.mb_xs,
|
||||
a.rounded_md,
|
||||
a.p_sm,
|
||||
a.flex_col,
|
||||
a.align_start,
|
||||
a.border,
|
||||
{borderColor, marginHorizontal: -4},
|
||||
]}>
|
||||
{senderName ? (
|
||||
<Text style={[a.text_xs, {color: subtleColor}]} emoji numberOfLines={1}>
|
||||
{senderName}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
{color: subtle ? subtleColor : tintColor},
|
||||
subtle && a.italic,
|
||||
]}
|
||||
emoji
|
||||
numberOfLines={2}>
|
||||
{text}
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {useWindowDimensions, View} from 'react-native'
|
||||
import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api'
|
||||
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {isKnownJoinLinkPreview} from '#/state/queries/join-links'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {MessageContextProvider} from './MessageContext'
|
||||
@@ -28,11 +27,6 @@ let MessageItemInviteEmbed = ({
|
||||
const screen = useWindowDimensions()
|
||||
const convo = useConvoActive()
|
||||
|
||||
const code = isKnownJoinLinkPreview(embed.joinLinkPreview)
|
||||
? embed.joinLinkPreview.code
|
||||
: undefined
|
||||
if (!code) return null
|
||||
|
||||
return (
|
||||
<MessageContextProvider>
|
||||
<View
|
||||
@@ -78,11 +72,12 @@ let MessageItemInviteEmbed = ({
|
||||
},
|
||||
]}>
|
||||
<ChatInvite.Root
|
||||
code={code}
|
||||
code={embed.joinLinkPreview.code}
|
||||
initialPreview={embed.joinLinkPreview}
|
||||
currentConvoId={convo.convo.view.id}
|
||||
hasFixedHeight={false}>
|
||||
<MessageItemInviteEmbedBody />
|
||||
<ChatInvite.Card size="small" />
|
||||
<ChatInvite.JoinButton />
|
||||
</ChatInvite.Root>
|
||||
</View>
|
||||
</View>
|
||||
@@ -91,22 +86,3 @@ let MessageItemInviteEmbed = ({
|
||||
}
|
||||
MessageItemInviteEmbed = memo(MessageItemInviteEmbed)
|
||||
export {MessageItemInviteEmbed}
|
||||
|
||||
function MessageItemInviteEmbedBody() {
|
||||
const {status} = ChatInvite.useChatInvite()
|
||||
|
||||
if (status === 'loading') {
|
||||
return <ChatInvite.Loading style={a.py_lg} />
|
||||
}
|
||||
|
||||
if (status !== 'available') {
|
||||
return <ChatInvite.Unavailable style={a.py_sm} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatInvite.Card size="small" />
|
||||
<ChatInvite.JoinButton />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* How long a message stays highlighted after scrolling to it, before the flash
|
||||
* fades out.
|
||||
*/
|
||||
export const MESSAGE_HIGHLIGHT_DURATION_MS = 1500
|
||||
|
||||
type HighlightedMessage = {
|
||||
id: string
|
||||
/**
|
||||
* Bumped on every highlight so that re-tapping the same reply re-triggers the
|
||||
* flash even while the previous highlight is still active.
|
||||
*/
|
||||
key: number
|
||||
}
|
||||
|
||||
type MessageRepliesContextType = {
|
||||
/**
|
||||
* The message currently staged for reply in the composer, or null.
|
||||
*/
|
||||
replyTo: ChatBskyConvoDefs.MessageView | null
|
||||
setReply: (message: ChatBskyConvoDefs.MessageView) => void
|
||||
clearReply: () => void
|
||||
/**
|
||||
* Scroll the list to a message, if it's currently loaded, and flash it. No-op
|
||||
* otherwise.
|
||||
*/
|
||||
scrollToMessage: (messageId: string) => void
|
||||
/**
|
||||
* The message to flash, or null. Consumers compare against their own id.
|
||||
*/
|
||||
highlightedMessage: HighlightedMessage | null
|
||||
}
|
||||
|
||||
const Context = createContext<MessageRepliesContextType | null>(null)
|
||||
|
||||
export function useMessageReplies() {
|
||||
const ctx = useContext(Context)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'useMessageReplies must be used within a MessageRepliesProvider',
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function MessageRepliesProvider({
|
||||
children,
|
||||
scrollToMessage: scrollToMessageRaw,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
/**
|
||||
* Performs the actual scroll. Returns true if the message was found and
|
||||
* scrolled to, false if it isn't currently loaded (so we know whether to
|
||||
* flash it).
|
||||
*/
|
||||
scrollToMessage: (messageId: string) => boolean
|
||||
}) {
|
||||
const [replyTo, setReplyTo] = useState<ChatBskyConvoDefs.MessageView | null>(
|
||||
null,
|
||||
)
|
||||
const [highlightedMessage, setHighlightedMessage] =
|
||||
useState<HighlightedMessage | null>(null)
|
||||
const highlightKey = useRef(0)
|
||||
const clearHighlightTimeout = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const setReply = useCallback((message: ChatBskyConvoDefs.MessageView) => {
|
||||
setReplyTo(message)
|
||||
}, [])
|
||||
|
||||
const clearReply = useCallback(() => {
|
||||
setReplyTo(null)
|
||||
}, [])
|
||||
|
||||
const scrollToMessage = useCallback(
|
||||
(messageId: string) => {
|
||||
const didScroll = scrollToMessageRaw(messageId)
|
||||
if (!didScroll) return
|
||||
|
||||
highlightKey.current += 1
|
||||
setHighlightedMessage({id: messageId, key: highlightKey.current})
|
||||
if (clearHighlightTimeout.current) {
|
||||
clearTimeout(clearHighlightTimeout.current)
|
||||
}
|
||||
clearHighlightTimeout.current = setTimeout(() => {
|
||||
setHighlightedMessage(null)
|
||||
}, MESSAGE_HIGHLIGHT_DURATION_MS)
|
||||
},
|
||||
[scrollToMessageRaw],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearHighlightTimeout.current) {
|
||||
clearTimeout(clearHighlightTimeout.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ctx = useMemo<MessageRepliesContextType>(
|
||||
() => ({
|
||||
replyTo,
|
||||
setReply,
|
||||
clearReply,
|
||||
scrollToMessage,
|
||||
highlightedMessage,
|
||||
}),
|
||||
[replyTo, setReply, clearReply, scrollToMessage, highlightedMessage],
|
||||
)
|
||||
|
||||
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||
}
|
||||
@@ -20,10 +20,10 @@ import * as Layout from '#/components/Layout'
|
||||
import {Link} from '#/components/Link'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_LIQUID_GLASS} from '#/env'
|
||||
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
|
||||
import {type ConvoWithDetails} from './util'
|
||||
|
||||
const PFP_SIZE = 40
|
||||
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
|
||||
|
||||
export function MessagesListHeader({convo}: {convo?: ConvoWithDetails | null}) {
|
||||
const t = useTheme()
|
||||
@@ -42,7 +42,7 @@ export function MessagesListHeader({convo}: {convo?: ConvoWithDetails | null}) {
|
||||
convo.kind === 'direct' ? (
|
||||
<ProfileHeaderReady convo={convo} moderationOpts={moderationOpts} />
|
||||
) : (
|
||||
<GroupHeaderReady convo={convo} />
|
||||
<GroupHeaderReady convo={convo} moderationOpts={moderationOpts} />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
@@ -121,8 +121,7 @@ function ProfileHeaderReady({
|
||||
<View style={[a.flex_row, a.align_center, a.flex_1, web(a.mb_2xs)]}>
|
||||
<Text
|
||||
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
|
||||
numberOfLines={1}
|
||||
emoji>
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
|
||||
@@ -152,8 +151,10 @@ function ProfileHeaderReady({
|
||||
|
||||
function GroupHeaderReady({
|
||||
convo,
|
||||
moderationOpts,
|
||||
}: {
|
||||
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
@@ -176,12 +177,15 @@ function GroupHeaderReady({
|
||||
},
|
||||
}
|
||||
}>
|
||||
<AvatarBubbles size={PFP_SIZE} profiles={convo.members} />
|
||||
<AvatarBubbles
|
||||
size={40}
|
||||
profiles={convo.members}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<View style={[a.flex_row, a.flex_1, a.align_center]}>
|
||||
<Text
|
||||
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
|
||||
numberOfLines={1}
|
||||
emoji>
|
||||
numberOfLines={1}>
|
||||
{convo.details.name}
|
||||
</Text>
|
||||
<MuteStatus muted={convo.view.muted} />
|
||||
|
||||
@@ -19,7 +19,6 @@ import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {filterBlockedReactions} from '#/components/dms/util'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
@@ -53,13 +52,10 @@ export function ReactionsDialog({
|
||||
|
||||
const [selected, setSelected] = useState('all')
|
||||
|
||||
const reactions = useMemo(
|
||||
() => filterBlockedReactions(message.reactions, relatedProfiles),
|
||||
[message.reactions, relatedProfiles],
|
||||
)
|
||||
const reactions = message.reactions
|
||||
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
|
||||
|
||||
const filteredReactions = reactions.filter(
|
||||
const filteredReactions = reactions?.filter(
|
||||
r => selected === 'all' || r.value === selected,
|
||||
)
|
||||
|
||||
@@ -73,7 +69,7 @@ export function ReactionsDialog({
|
||||
<ReactionTabs
|
||||
groupedReactions={groupedReactions}
|
||||
selected={selected}
|
||||
totalReactions={reactions.length}
|
||||
totalReactions={reactions?.length ?? 0}
|
||||
onFilter={setSelected}
|
||||
/>
|
||||
<Dialog.Close />
|
||||
@@ -100,7 +96,7 @@ export function ReactionsDialog({
|
||||
header={IS_WEB ? header : null}
|
||||
style={[web({maxWidth: 400})]}>
|
||||
{filteredReactions
|
||||
.sort((a, b) => {
|
||||
?.sort((a, b) => {
|
||||
if (a.sender.did === currentAccount?.did) return -1
|
||||
if (b.sender.did === currentAccount?.did) return 1
|
||||
return 0
|
||||
@@ -117,7 +113,7 @@ export function ReactionsDialog({
|
||||
message={message}
|
||||
profile={sender}
|
||||
reaction={reaction}
|
||||
allReactions={reactions}
|
||||
allReactions={reactions ?? []}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
/>
|
||||
|
||||
@@ -45,8 +45,7 @@ export function SystemMessageItem({
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_medium,
|
||||
{includeFontPadding: false, textAlignVertical: 'center'},
|
||||
]}
|
||||
emoji>
|
||||
]}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -23,13 +23,9 @@ import {useAnalytics} from '#/analytics'
|
||||
export function NewChat({
|
||||
control,
|
||||
onNewChat,
|
||||
startInGroupChat = false,
|
||||
onClose,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onNewChat: (chatId: string) => void
|
||||
startInGroupChat?: boolean
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
@@ -38,7 +34,7 @@ export function NewChat({
|
||||
const {data: chatStatus} = useChatActorStatusQuery()
|
||||
const chatDisabled = !!chatStatus?.chatDisabled
|
||||
|
||||
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
|
||||
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
|
||||
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
@@ -173,18 +169,13 @@ export function NewChat({
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{fullHeight: true}}
|
||||
onClose={onClose}>
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
{isGroupChatEnabled ? (
|
||||
<InitiateChatFlow
|
||||
// remount when the entry mode changes so the flow re-seeds its
|
||||
// initial step (the children stay mounted across open/close)
|
||||
key={startInGroupChat ? 'group' : 'default'}
|
||||
title={l`New chat`}
|
||||
onSelectChat={onCreateChat}
|
||||
onSelectGroupChat={onCreateGroupChat}
|
||||
startInGroupChat={startInGroupChat}
|
||||
/>
|
||||
) : (
|
||||
<SearchablePeopleList
|
||||
|
||||
@@ -1,61 +1,27 @@
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
type ChatBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
ChatBskyEmbedJoinLink,
|
||||
} from '@atproto/api'
|
||||
import {AppBskyEmbedRecord, ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {
|
||||
postUriToRelativePath,
|
||||
toBskyAppUrl,
|
||||
toShortUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export type UserMessageInfo = {
|
||||
message: string | null
|
||||
sentAt: string
|
||||
reportableMessage?: ChatBskyConvoDefs.MessageView
|
||||
isBlockedMessage: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves whether the given did is blocked (in either direction) within a
|
||||
* convo. Prefers the passed-in shadowed `primaryProfile` so optimistic blocks
|
||||
* reflect immediately, before the convo list refetches - the raw `members`
|
||||
* fetched with the convo are invisible to the profile shadow cache. Group
|
||||
* members other than the owner fall back to the raw (potentially stale) member.
|
||||
*/
|
||||
export function isDidBlockedInConvo({
|
||||
did,
|
||||
members,
|
||||
primaryProfile,
|
||||
}: {
|
||||
did: string | undefined
|
||||
members: ChatBskyActorDefs.ProfileViewBasic[]
|
||||
primaryProfile?: bsky.profile.AnyProfileView
|
||||
}): boolean {
|
||||
if (!did) return false
|
||||
if (primaryProfile && primaryProfile.did === did) {
|
||||
return isBlockedOrBlocking(primaryProfile)
|
||||
}
|
||||
const member = members.find(m => m.did === did)
|
||||
return member ? isBlockedOrBlocking(member) : false
|
||||
}
|
||||
|
||||
export function getMessageInfo({
|
||||
convo,
|
||||
currentAccountDid,
|
||||
primaryProfile,
|
||||
i18n,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
currentAccountDid: string | undefined
|
||||
primaryProfile?: bsky.profile.AnyProfileView
|
||||
i18n: I18n
|
||||
}): UserMessageInfo | null {
|
||||
if (!ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
|
||||
@@ -70,11 +36,6 @@ export function getMessageInfo({
|
||||
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
||||
|
||||
const reportableMessage = isFromMe ? undefined : lastMessage
|
||||
const isBlockedMessage = isDidBlockedInConvo({
|
||||
did: senderDid,
|
||||
members: convo.members,
|
||||
primaryProfile,
|
||||
})
|
||||
|
||||
const prefix = (message: string) => {
|
||||
if (isFromMe) {
|
||||
@@ -119,8 +80,6 @@ export function getMessageInfo({
|
||||
} else {
|
||||
message = prefix(defaultEmbeddedContentMessage)
|
||||
}
|
||||
} else if (ChatBskyEmbedJoinLink.isView(lastMessage.embed)) {
|
||||
message = prefix(i18n._(msg`(chat invite link)`))
|
||||
} else {
|
||||
message = prefix(defaultEmbeddedContentMessage)
|
||||
}
|
||||
@@ -130,6 +89,5 @@ export function getMessageInfo({
|
||||
message,
|
||||
sentAt: lastMessage.sentAt,
|
||||
reportableMessage,
|
||||
isBlockedMessage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,24 +3,19 @@ import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {isDidBlockedInConvo} from '#/components/dms/getMessageInfo'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export type UserReactionInfo = {
|
||||
message: string
|
||||
createdAt: string
|
||||
isBlocked: boolean
|
||||
}
|
||||
|
||||
export function getReactionInfo({
|
||||
convo,
|
||||
currentAccountDid,
|
||||
primaryProfile,
|
||||
i18n,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
currentAccountDid: string | undefined
|
||||
primaryProfile?: bsky.profile.AnyProfileView
|
||||
i18n: I18n
|
||||
}): UserReactionInfo | null {
|
||||
if (!ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
|
||||
@@ -33,21 +28,6 @@ export function getReactionInfo({
|
||||
const sender = convo.members.find(m => m.did === senderDid)
|
||||
const name = sender ? createSanitizedDisplayName(sender) : null
|
||||
|
||||
// Hide the preview when either the reactor or the author of the reacted-to
|
||||
// message is blocked - otherwise a blocked reactor's name or a blocked
|
||||
// sender's message text would leak into the chat list.
|
||||
const isBlocked =
|
||||
isDidBlockedInConvo({
|
||||
did: senderDid,
|
||||
members: convo.members,
|
||||
primaryProfile,
|
||||
}) ||
|
||||
isDidBlockedInConvo({
|
||||
did: reactedTo.sender?.did,
|
||||
members: convo.members,
|
||||
primaryProfile,
|
||||
})
|
||||
|
||||
const lastMessageText = reactedTo.text
|
||||
const fallbackMessage = i18n._(
|
||||
msg({
|
||||
@@ -70,6 +50,5 @@ export function getReactionInfo({
|
||||
return {
|
||||
message,
|
||||
createdAt: reaction.createdAt,
|
||||
isBlocked,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@atproto/api'
|
||||
|
||||
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
||||
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/profile-shadow'
|
||||
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
|
||||
@@ -87,25 +86,6 @@ export function hasAlreadyReacted(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops reactions from accounts the viewer is blocking or blocked by, so a
|
||||
* blocked reactor's identity is never surfaced via the reaction pills or the
|
||||
* reactions dialog. `relatedProfiles` is shadow-synced by the convo agent, so
|
||||
* this reflects optimistic blocks. Reactions whose sender isn't in
|
||||
* `relatedProfiles` are kept - we can't determine their block status, and they
|
||||
* already render anonymously ("Someone reacted").
|
||||
*/
|
||||
export function filterBlockedReactions(
|
||||
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
|
||||
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
|
||||
): ChatBskyConvoDefs.ReactionView[] {
|
||||
if (!reactions) return []
|
||||
return reactions.filter(reaction => {
|
||||
const profile = relatedProfiles.get(reaction.sender.did)
|
||||
return !profile || !isBlockedOrBlocking(profile)
|
||||
})
|
||||
}
|
||||
|
||||
export function hasReachedReactionLimit(
|
||||
message: ChatBskyConvoDefs.MessageView,
|
||||
myDid: string | undefined,
|
||||
|
||||
@@ -67,6 +67,7 @@ export function DateField({
|
||||
isInvalid={isInvalid}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
// Android implementation of DatePicker currently does not change default button colors according to theme and only takes hex values for buttonColor
|
||||
// Can remove the buttonColor setting if/when this PR is merged: https://github.com/henninghall/react-native-date-picker/pull/871
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import {useCallback, useEffect, useRef, useSyncExternalStore} from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
|
||||
import {IS_WEB, IS_WEB_FIREFOX, IS_WEB_SAFARI} from '#/env'
|
||||
|
||||
@@ -7,38 +13,28 @@ function fullscreenSubscribe(onChange: () => void) {
|
||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||
}
|
||||
|
||||
function getFullscreenSnapshot() {
|
||||
return Boolean(document.fullscreenElement)
|
||||
}
|
||||
|
||||
export function useFullscreen(ref?: React.RefObject<HTMLElement | null>) {
|
||||
if (!IS_WEB) throw new Error("'useFullscreen' is a web-only hook")
|
||||
const isFullscreen = useSyncExternalStore(
|
||||
fullscreenSubscribe,
|
||||
getFullscreenSnapshot,
|
||||
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
|
||||
Boolean(document.fullscreenElement),
|
||||
)
|
||||
const scrollYRef = useRef<null | number>(null)
|
||||
// Tracked via a ref rather than state so that reacting to a fullscreen change
|
||||
// never schedules its own render. Scheduling a render in response to the
|
||||
// external store value (the old `setPrevIsFullscreen` pattern) was a seed for
|
||||
// the commit-phase update loop reported in APP-315 / APP-5PP / APP-7ZB.
|
||||
const prevIsFullscreenRef = useRef(isFullscreen)
|
||||
const [prevIsFullscreen, setPrevIsFullscreen] = useState(isFullscreen)
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
void document.exitFullscreen()
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
if (!ref) throw new Error('No ref provided')
|
||||
if (!ref.current) return
|
||||
scrollYRef.current = window.scrollY
|
||||
void ref.current.requestFullscreen()
|
||||
ref.current.requestFullscreen()
|
||||
}
|
||||
}, [isFullscreen, ref])
|
||||
|
||||
useEffect(() => {
|
||||
const prevIsFullscreen = prevIsFullscreenRef.current
|
||||
if (prevIsFullscreen === isFullscreen) return
|
||||
prevIsFullscreenRef.current = isFullscreen
|
||||
setPrevIsFullscreen(isFullscreen)
|
||||
|
||||
// Chrome has an issue where it doesn't scroll back to the top after exiting fullscreen
|
||||
// Let's play it safe and do it if not FF or Safari, since anything else will probably be chromium
|
||||
@@ -50,7 +46,7 @@ export function useFullscreen(ref?: React.RefObject<HTMLElement | null>) {
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, [isFullscreen])
|
||||
}, [isFullscreen, prevIsFullscreen])
|
||||
|
||||
return [isFullscreen, toggleFullscreen] as const
|
||||
}
|
||||
|
||||