Merge remote-tracking branch 'origin/main' into app-2098
* origin/main: (26 commits) [Android] Fix horizontal swipes being cancelled when leaving bounds (#10712) Enable ESLint errors and suppress existing violations (#10490) Hide accept button for locked chats (#10696) Mount message dialogs once at the list level (#10435) Fix alt text selection in lightbox on Android (and iOS) (#10698) Mention default `<Text>` style in CLAUDE.md (#10705) Use square icons for labellers in Automation Label preview card (#10701) [ALF] Set text to `leading_snug` by default (#10627) Nightly source-language update Transpile `@atproto/api` package for older browser support (#10700) Add iOS peek long-press context menu for image embeds (#10300) Revert "[ALG-96] Don't fall back to search for onboarding user suggestions (#10680)" (#10699) fix blur on image alt text background (#10658) Create screen for managing chat requests (#10635) Don't show Suggested heading if there are no suggestions (#10695) Prefer opengraph image, fall back to Standard Site coverImage (#10694) Create logged-out view for group chat invites (#10598) Bump actions/create-github-app-token from 1 to 3 (#10691) Bump marocchino/sticky-pull-request-comment from 2 to 3 (#10692) Bump slackapi/slack-github-action from 2.1.1 to 3.0.3 (#10693) ... # Conflicts: # src/components/Post/Embed/ImageEmbed.tsx
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
alt-blur@3076a2b5a593b0d7b37198b5aebfe3a222afd52d
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
version: 2
|
||||||
|
# Dependabot auto-update config.
|
||||||
|
#
|
||||||
|
# Cooldown (7 days) is the point of this config: it delays version-update
|
||||||
|
# PRs until a newly-published version has aged. Supply-chain attacks like
|
||||||
|
# the tanstack Shai-Hulud compromise (2026-05-11) live minutes-to-hours
|
||||||
|
# before the registry yanks them; a 7-day cooldown keeps poisoned
|
||||||
|
# versions out of our lockfiles.
|
||||||
|
#
|
||||||
|
# Security updates bypass cooldown and continue to flow immediately. See:
|
||||||
|
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown
|
||||||
|
#
|
||||||
|
# Auto-merge is deliberately NOT enabled. Every dependabot PR gets human
|
||||||
|
# review.
|
||||||
|
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: monday
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
groups:
|
||||||
|
production:
|
||||||
|
dependency-type: production
|
||||||
|
update-types: [minor, patch]
|
||||||
|
development:
|
||||||
|
dependency-type: development
|
||||||
|
update-types: [minor, patch]
|
||||||
|
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: monday
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
open-pull-requests-limit: 3
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns: ["*"]
|
||||||
|
update-types: [minor, patch]
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: 🔨 Setup Expo CLI
|
- name: 🔨 Setup Expo CLI
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@v9
|
||||||
with:
|
with:
|
||||||
eas-version: '19.0.5'
|
eas-version: '19.0.5'
|
||||||
packager: 'pnpm --allow-build=dtrace-provider'
|
packager: 'pnpm --allow-build=dtrace-provider'
|
||||||
@@ -128,7 +128,7 @@ jobs:
|
|||||||
|
|
||||||
- name: 🔔 Notify Slack of Production Build
|
- name: 🔔 Notify Slack of Production Build
|
||||||
if: ${{ inputs.profile == 'production' }}
|
if: ${{ inputs.profile == 'production' }}
|
||||||
uses: slackapi/slack-github-action@v2.1.1
|
uses: slackapi/slack-github-action@v3.0.3
|
||||||
with:
|
with:
|
||||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||||
webhook-type: incoming-webhook
|
webhook-type: incoming-webhook
|
||||||
@@ -138,7 +138,7 @@ jobs:
|
|||||||
|
|
||||||
- name: 🔔 Notify Slack of Testflight Build
|
- name: 🔔 Notify Slack of Testflight Build
|
||||||
if: ${{ inputs.profile != 'production' }}
|
if: ${{ inputs.profile != 'production' }}
|
||||||
uses: slackapi/slack-github-action@v2.1.1
|
uses: slackapi/slack-github-action@v3.0.3
|
||||||
with:
|
with:
|
||||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||||
webhook-type: incoming-webhook
|
webhook-type: incoming-webhook
|
||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
|
|
||||||
- name: 🔔 Notify Slack of Production APK Build
|
- name: 🔔 Notify Slack of Production APK Build
|
||||||
if: ${{ inputs.profile == 'production' }}
|
if: ${{ inputs.profile == 'production' }}
|
||||||
uses: slackapi/slack-github-action@v2.1.1
|
uses: slackapi/slack-github-action@v3.0.3
|
||||||
with:
|
with:
|
||||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||||
webhook-type: incoming-webhook
|
webhook-type: incoming-webhook
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: 🔨 Setup Expo CLI
|
- name: 🔨 Setup Expo CLI
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@v9
|
||||||
with:
|
with:
|
||||||
eas-version: '19.0.5'
|
eas-version: '19.0.5'
|
||||||
packager: 'pnpm --allow-build=dtrace-provider'
|
packager: 'pnpm --allow-build=dtrace-provider'
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
|
|
||||||
- name: 🔔 Notify Slack of Production Build
|
- name: 🔔 Notify Slack of Production Build
|
||||||
if: ${{ inputs.profile == 'production' }}
|
if: ${{ inputs.profile == 'production' }}
|
||||||
uses: slackapi/slack-github-action@v2.1.1
|
uses: slackapi/slack-github-action@v3.0.3
|
||||||
with:
|
with:
|
||||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||||
webhook-type: incoming-webhook
|
webhook-type: incoming-webhook
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
run: pnpm typecheck
|
run: pnpm typecheck
|
||||||
|
|
||||||
- name: 🔨 Setup EAS
|
- name: 🔨 Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@v9
|
||||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||||
with:
|
with:
|
||||||
eas-version: '19.0.5'
|
eas-version: '19.0.5'
|
||||||
@@ -180,7 +180,7 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: 🔨 Setup EAS
|
- name: 🔨 Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@v9
|
||||||
with:
|
with:
|
||||||
eas-version: '19.0.5'
|
eas-version: '19.0.5'
|
||||||
packager: 'pnpm --allow-build=dtrace-provider'
|
packager: 'pnpm --allow-build=dtrace-provider'
|
||||||
@@ -327,7 +327,7 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: 🔨 Setup EAS
|
- name: 🔨 Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@v9
|
||||||
with:
|
with:
|
||||||
eas-version: '19.0.5'
|
eas-version: '19.0.5'
|
||||||
packager: 'pnpm --allow-build=dtrace-provider'
|
packager: 'pnpm --allow-build=dtrace-provider'
|
||||||
@@ -386,7 +386,7 @@ jobs:
|
|||||||
path: build.apk
|
path: build.apk
|
||||||
|
|
||||||
- name: 🔔 Notify Slack
|
- name: 🔔 Notify Slack
|
||||||
uses: slackapi/slack-github-action@v2.1.1
|
uses: slackapi/slack-github-action@v3.0.3
|
||||||
with:
|
with:
|
||||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||||
webhook-type: incoming-webhook
|
webhook-type: incoming-webhook
|
||||||
|
|||||||
+54
-13
@@ -6,33 +6,42 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
|
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# These jobs only check out the repo and run checks, so read access to the
|
||||||
|
# repo contents is all the GITHUB_TOKEN needs.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
linting:
|
linting:
|
||||||
name: Run linters
|
name: Run linters
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
job: [lint, prettier, typecheck]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
- name: Verify Node version pins match .nvmrc
|
- name: Verify Node version pins match package.json
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
expected=$(tr -d '[:space:]' < .nvmrc)
|
expected=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g, '')")
|
||||||
rc=0
|
rc=0
|
||||||
check() {
|
check() {
|
||||||
if [ "$2" != "$expected" ]; then
|
if [ "$2" != "$expected" ]; then
|
||||||
echo "::error file=$1::Node version mismatch: $1 pins '$2' but .nvmrc is '$expected'"
|
echo "::error file=$1::Node version mismatch: $1 pins '$2' but package.json is '$expected'"
|
||||||
rc=1
|
rc=1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
# FROM node:X.Y.Z — service runtime images
|
# FROM node:X.Y.Z - service runtime images
|
||||||
for f in Dockerfile.bskylink Dockerfile.bskyogcard; do
|
for f in Dockerfile.bskylink Dockerfile.bskyogcard; do
|
||||||
v=$(grep -oE 'FROM node:[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
|
v=$(grep -oE 'FROM node:[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
|
||||||
check "$f" "$v"
|
check "$f" "$v"
|
||||||
done
|
done
|
||||||
# ENV NODE_VERSION=X.Y.Z — Go images that nvm-install Node for the JS build stage
|
# ENV NODE_VERSION=X.Y.Z - Go images that nvm-install Node for the JS build stage
|
||||||
for f in Dockerfile.embedr; do
|
for f in Dockerfile.embedr; do
|
||||||
v=$(grep -oE 'NODE_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
|
v=$(grep -oE 'NODE_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
|
||||||
check "$f" "$v"
|
check "$f" "$v"
|
||||||
@@ -53,17 +62,33 @@ jobs:
|
|||||||
command: pnpm install --frozen-lockfile
|
command: pnpm install --frozen-lockfile
|
||||||
attempt_limit: 3
|
attempt_limit: 3
|
||||||
attempt_delay: 2000
|
attempt_delay: 2000
|
||||||
- name: Lint check
|
|
||||||
run: pnpm lint
|
|
||||||
- name: Prettier check
|
|
||||||
run: pnpm prettier --check .
|
|
||||||
- name: Check & compile i18n
|
- name: Check & compile i18n
|
||||||
run: pnpm intl:build
|
run: pnpm intl:build
|
||||||
- name: Type check
|
- name: Lint checks
|
||||||
run: pnpm typecheck
|
run: pnpm ${{ matrix.job }}
|
||||||
|
# Aggregates the matrix results into a single stable check name so branch
|
||||||
|
# protection can require "Run linters" regardless of how many matrix jobs run.
|
||||||
|
# The result is asserted in `run` (not `if`) so a malformed expression can
|
||||||
|
# never silently skip the check and report a false pass.
|
||||||
|
linting-summary:
|
||||||
|
name: Run linters
|
||||||
|
if: always()
|
||||||
|
needs: [linting]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Require linting to have succeeded
|
||||||
|
env:
|
||||||
|
RESULT: ${{ needs.linting.result }}
|
||||||
|
run: |
|
||||||
|
echo "linting result: $RESULT"
|
||||||
|
test "$RESULT" = "success"
|
||||||
testing:
|
testing:
|
||||||
name: Run tests
|
name: Run tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shard: [1, 2, 3, 4]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -83,4 +108,20 @@ jobs:
|
|||||||
run: pnpm intl:build
|
run: pnpm intl:build
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
NODE_ENV=test pnpm test --forceExit
|
NODE_ENV=test pnpm test --forceExit --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||||
|
# Aggregates the sharded test results into a single stable check name so branch
|
||||||
|
# protection can require "Run tests" regardless of how many shards run.
|
||||||
|
# The result is asserted in `run` (not `if`) so a malformed expression can
|
||||||
|
# never silently skip the check and report a false pass.
|
||||||
|
testing-summary:
|
||||||
|
name: Run tests
|
||||||
|
if: always()
|
||||||
|
needs: [testing]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Require testing to have succeeded
|
||||||
|
env:
|
||||||
|
RESULT: ${{ needs.testing.result }}
|
||||||
|
run: |
|
||||||
|
echo "testing result: $RESULT"
|
||||||
|
test "$RESULT" = "success"
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
core.setOutput('head-ref', pr.data.head.ref);
|
core.setOutput('head-ref', pr.data.head.ref);
|
||||||
|
|
||||||
- name: 💬 Drop a comment
|
- name: 💬 Drop a comment
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
uses: marocchino/sticky-pull-request-comment@v3
|
||||||
with:
|
with:
|
||||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||||
number: ${{ github.event.issue.number }}
|
number: ${{ github.event.issue.number }}
|
||||||
@@ -141,7 +141,7 @@ jobs:
|
|||||||
run: pnpm typecheck
|
run: pnpm typecheck
|
||||||
|
|
||||||
- name: 🔨 Setup EAS
|
- name: 🔨 Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@v9
|
||||||
with:
|
with:
|
||||||
eas-version: '19.0.5'
|
eas-version: '19.0.5'
|
||||||
packager: 'pnpm --allow-build=dtrace-provider'
|
packager: 'pnpm --allow-build=dtrace-provider'
|
||||||
@@ -181,7 +181,7 @@ jobs:
|
|||||||
RUNTIME_VERSION:
|
RUNTIME_VERSION:
|
||||||
|
|
||||||
- name: 💬 Drop a comment
|
- name: 💬 Drop a comment
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
uses: marocchino/sticky-pull-request-comment@v3
|
||||||
env:
|
env:
|
||||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||||
with:
|
with:
|
||||||
@@ -198,7 +198,7 @@ jobs:
|
|||||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||||
|
|
||||||
- name: 💬 Drop a comment
|
- name: 💬 Drop a comment
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
uses: marocchino/sticky-pull-request-comment@v3
|
||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ jobs:
|
|||||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||||
|
|
||||||
- name: 💬 Drop a comment
|
- name: 💬 Drop a comment
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
uses: marocchino/sticky-pull-request-comment@v3
|
||||||
with:
|
with:
|
||||||
header: bundle-diff
|
header: bundle-diff
|
||||||
message: |
|
message: |
|
||||||
@@ -133,7 +133,7 @@ jobs:
|
|||||||
profile: pull-request
|
profile: pull-request
|
||||||
|
|
||||||
- name: 💬 Drop a comment
|
- name: 💬 Drop a comment
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
uses: marocchino/sticky-pull-request-comment@v3
|
||||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||||
with:
|
with:
|
||||||
header: fingerprint-diff
|
header: fingerprint-diff
|
||||||
@@ -151,7 +151,7 @@ jobs:
|
|||||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||||
|
|
||||||
- name: 💬 Delete comment
|
- name: 💬 Delete comment
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
uses: marocchino/sticky-pull-request-comment@v3
|
||||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||||
with:
|
with:
|
||||||
header: fingerprint-diff
|
header: fingerprint-diff
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Generate GitHub App Token
|
- name: Generate GitHub App Token
|
||||||
id: app-token
|
id: app-token
|
||||||
uses: actions/create-github-app-token@v1
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ function MyComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
||||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
|
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -414,12 +414,16 @@ import {Text, H1, H2, P} from '#/components/Typography'
|
|||||||
|
|
||||||
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
|
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
|
||||||
<P>Paragraph text with default styling.</P>
|
<P>Paragraph text with default styling.</P>
|
||||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
|
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
|
||||||
|
|
||||||
// For text with emoji, add the emoji prop
|
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
|
||||||
|
// will almost certainly contain emoji, so only omit it when the text is static and
|
||||||
|
// does not contain an emoji
|
||||||
<Text emoji>Hello! 👋</Text>
|
<Text emoji>Hello! 👋</Text>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
|
||||||
|
|
||||||
### TextField
|
### TextField
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -339,6 +339,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
e.GET("/messages/inbox", server.WebGeneric)
|
e.GET("/messages/inbox", server.WebGeneric)
|
||||||
e.GET("/messages/:conversation", server.WebGeneric)
|
e.GET("/messages/:conversation", server.WebGeneric)
|
||||||
e.GET("/messages/:conversation/settings", server.WebGeneric)
|
e.GET("/messages/:conversation/settings", server.WebGeneric)
|
||||||
|
e.GET("/messages/:conversation/requests", server.WebGeneric)
|
||||||
|
|
||||||
// profile endpoints; only first populates info
|
// profile endpoints; only first populates info
|
||||||
e.GET("/profile/:handleOrDID", server.WebProfile)
|
e.GET("/profile/:handleOrDID", server.WebProfile)
|
||||||
|
|||||||
+1834
-2
File diff suppressed because it is too large
Load Diff
+18
-20
@@ -134,11 +134,10 @@ export default defineConfig(
|
|||||||
'react-native/no-inline-styles': 'off',
|
'react-native/no-inline-styles': 'off',
|
||||||
...reactNativeA11y.configs.all.rules,
|
...reactNativeA11y.configs.all.rules,
|
||||||
'react-compiler/react-compiler': 'warn',
|
'react-compiler/react-compiler': 'warn',
|
||||||
// TODO: Fix these and set to error
|
'react-hooks/set-state-in-effect': 'error',
|
||||||
'react-hooks/set-state-in-effect': 'warn',
|
'react-hooks/purity': 'error',
|
||||||
'react-hooks/purity': 'warn',
|
'react-hooks/refs': 'error',
|
||||||
'react-hooks/refs': 'warn',
|
'react-hooks/immutability': 'error',
|
||||||
'react-hooks/immutability': 'warn',
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import sorting
|
* Import sorting
|
||||||
@@ -235,9 +234,8 @@ export default defineConfig(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
/**
|
/**
|
||||||
* Maintain previous behavior - these are stricter in typescript-eslint
|
* Maintain previous behavior via eslint-suppressions.json - these are
|
||||||
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
|
* stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
|
||||||
* nit-picky
|
|
||||||
*/
|
*/
|
||||||
'@typescript-eslint/no-explicit-any': 'error',
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
'@typescript-eslint/ban-ts-comment': 'off',
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
@@ -247,18 +245,18 @@ export default defineConfig(
|
|||||||
'@typescript-eslint/unbound-method': 'off',
|
'@typescript-eslint/unbound-method': 'off',
|
||||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||||
'@typescript-eslint/no-unsafe-return': 'off',
|
'@typescript-eslint/no-unsafe-return': 'off',
|
||||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
'@typescript-eslint/no-unsafe-call': 'error',
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
'@typescript-eslint/no-misused-promises': 'warn',
|
'@typescript-eslint/no-misused-promises': 'error',
|
||||||
'@typescript-eslint/require-await': 'warn',
|
'@typescript-eslint/require-await': 'error',
|
||||||
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
|
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||||
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
|
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
'@typescript-eslint/no-redundant-type-constituents': 'error',
|
||||||
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
|
'@typescript-eslint/no-duplicate-type-constituents': 'error',
|
||||||
'@typescript-eslint/no-base-to-string': 'warn',
|
'@typescript-eslint/no-base-to-string': 'error',
|
||||||
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
|
'@typescript-eslint/prefer-promise-reject-errors': 'error',
|
||||||
'@typescript-eslint/await-thenable': 'warn',
|
'@typescript-eslint/await-thenable': 'error',
|
||||||
|
|
||||||
'no-restricted-imports': [
|
'no-restricted-imports': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
+19
-14
@@ -41,11 +41,14 @@ class BottomSheetView(
|
|||||||
private val screenHeight: Float =
|
private val screenHeight: Float =
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||||
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
||||||
context.resources.displayMetrics.heightPixels.toFloat()
|
context.resources.displayMetrics.heightPixels
|
||||||
|
.toFloat()
|
||||||
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||||
wm.currentWindowMetrics.bounds.height().toFloat()
|
wm.currentWindowMetrics.bounds
|
||||||
|
.height()
|
||||||
|
.toFloat()
|
||||||
} else {
|
} else {
|
||||||
// API < 30: currentWindowMetrics not available, use getRealSize
|
// API < 30: currentWindowMetrics not available, use getRealSize
|
||||||
// which includes system bars (heightPixels may exclude them)
|
// which includes system bars (heightPixels may exclude them)
|
||||||
@@ -166,6 +169,7 @@ class BottomSheetView(
|
|||||||
when {
|
when {
|
||||||
// Full height sheets
|
// Full height sheets
|
||||||
contentHeight >= screenHeight -> 0.99f
|
contentHeight >= screenHeight -> 0.99f
|
||||||
|
|
||||||
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
|
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,8 +275,9 @@ class BottomSheetView(
|
|||||||
}
|
}
|
||||||
// Apply deferred layout update after gesture completes
|
// Apply deferred layout update after gesture completes
|
||||||
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
|
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
|
||||||
newState != BottomSheetBehavior.STATE_SETTLING &&
|
newState != BottomSheetBehavior.STATE_SETTLING &&
|
||||||
pendingLayoutUpdate) {
|
pendingLayoutUpdate
|
||||||
|
) {
|
||||||
pendingLayoutUpdate = false
|
pendingLayoutUpdate = false
|
||||||
updateLayout()
|
updateLayout()
|
||||||
}
|
}
|
||||||
@@ -292,7 +297,6 @@ class BottomSheetView(
|
|||||||
if (!fullHeight) {
|
if (!fullHeight) {
|
||||||
this.startObservingContentHeight()
|
this.startObservingContentHeight()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLayout() {
|
fun updateLayout() {
|
||||||
@@ -365,17 +369,18 @@ class BottomSheetView(
|
|||||||
|
|
||||||
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
||||||
|
|
||||||
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
val listener =
|
||||||
val newHeight = bottom - top
|
OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||||
val oldHeight = oldBottom - oldTop
|
val newHeight = bottom - top
|
||||||
if (newHeight != oldHeight) {
|
val oldHeight = oldBottom - oldTop
|
||||||
val contentHeight = getContentHeight()
|
if (newHeight != oldHeight) {
|
||||||
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
val contentHeight = getContentHeight()
|
||||||
lastObservedContentHeight = contentHeight
|
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||||
updateLayout()
|
lastObservedContentHeight = contentHeight
|
||||||
|
updateLayout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val children = mutableListOf<View>()
|
val children = mutableListOf<View>()
|
||||||
for (i in 0 until innerViewGroup.childCount) {
|
for (i in 0 until innerViewGroup.childCount) {
|
||||||
|
|||||||
+3
-1
@@ -89,7 +89,8 @@
|
|||||||
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
|
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
|
||||||
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
|
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
|
||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
|
||||||
"icons:optimize": "svgo -f ./assets/icons"
|
"icons:optimize": "svgo -f ./assets/icons",
|
||||||
|
"prettier": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.20.8",
|
"@atproto/api": "0.20.8",
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||||
|
"@bsky.app/peek-menu": "^0.2.4",
|
||||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||||
"@bsky.app/sift": "^0.3.8",
|
"@bsky.app/sift": "^0.3.8",
|
||||||
"@bsky.app/tapper": "^0.5.7",
|
"@bsky.app/tapper": "^0.5.7",
|
||||||
|
|||||||
Generated
+16
@@ -268,6 +268,9 @@ importers:
|
|||||||
'@bsky.app/expo-translate-text':
|
'@bsky.app/expo-translate-text':
|
||||||
specifier: ^0.2.9
|
specifier: ^0.2.9
|
||||||
version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||||
|
'@bsky.app/peek-menu':
|
||||||
|
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':
|
'@bsky.app/react-native-mmkv':
|
||||||
specifier: 2.12.5
|
specifier: 2.12.5
|
||||||
version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||||
@@ -1649,6 +1652,13 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
'@bsky.app/peek-menu@0.2.4':
|
||||||
|
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
react: '*'
|
||||||
|
react-native: '*'
|
||||||
|
|
||||||
'@bsky.app/react-native-mmkv@2.12.5':
|
'@bsky.app/react-native-mmkv@2.12.5':
|
||||||
resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==}
|
resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -10461,6 +10471,12 @@ snapshots:
|
|||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||||
|
|
||||||
|
'@bsky.app/peek-menu@0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||||
|
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
|
||||||
|
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||||
|
|
||||||
'@bsky.app/react-native-mmkv@2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
'@bsky.app/react-native-mmkv@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)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|||||||
+6
-6
@@ -47,18 +47,18 @@ import {
|
|||||||
import {readLastActiveAccount} from '#/state/session/util'
|
import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||||
|
import {Provider as LandingProvider} from '#/state/shell/landing'
|
||||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
|
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
|
||||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
|
||||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||||
import {Shell} from '#/view/shell'
|
import {Shell} from '#/view/shell'
|
||||||
import {atoms as a, ThemeProvider as Alf} from '#/alf'
|
import {atoms as a, ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
|
||||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
|
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
|
||||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||||
@@ -113,7 +113,7 @@ function InnerApp() {
|
|||||||
const {resumeSession} = useSessionApi()
|
const {resumeSession} = useSessionApi()
|
||||||
const theme = useColorModeTheme()
|
const theme = useColorModeTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const hasCheckedReferrer = useStarterPackEntry()
|
const hasCheckedLanding = useLandingEntry()
|
||||||
|
|
||||||
// init
|
// init
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,7 +146,7 @@ function InnerApp() {
|
|||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<ContextMenuProvider>
|
<ContextMenuProvider>
|
||||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
<Splash isReady={isReady && hasCheckedLanding}>
|
||||||
<VideoVolumeProvider>
|
<VideoVolumeProvider>
|
||||||
<Fragment
|
<Fragment
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
@@ -246,12 +246,12 @@ function App() {
|
|||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<BottomSheetProvider>
|
<BottomSheetProvider>
|
||||||
<StarterPackProvider>
|
<LandingProvider>
|
||||||
<SafeAreaProvider
|
<SafeAreaProvider
|
||||||
initialMetrics={initialWindowMetrics}>
|
initialMetrics={initialWindowMetrics}>
|
||||||
<InnerApp />
|
<InnerApp />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</StarterPackProvider>
|
</LandingProvider>
|
||||||
</BottomSheetProvider>
|
</BottomSheetProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|||||||
+6
-6
@@ -40,17 +40,17 @@ import {
|
|||||||
import {readLastActiveAccount} from '#/state/session/util'
|
import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||||
|
import {Provider as LandingProvider} from '#/state/shell/landing'
|
||||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
|
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
|
||||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
|
||||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||||
import {Shell} from '#/view/shell/index'
|
import {Shell} from '#/view/shell/index'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
|
||||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
|
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
|
||||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||||
@@ -92,7 +92,7 @@ function InnerApp() {
|
|||||||
const {resumeSession} = useSessionApi()
|
const {resumeSession} = useSessionApi()
|
||||||
const theme = useColorModeTheme()
|
const theme = useColorModeTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const hasCheckedReferrer = useStarterPackEntry()
|
const hasCheckedLanding = useLandingEntry()
|
||||||
|
|
||||||
// init
|
// init
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,7 +125,7 @@ function InnerApp() {
|
|||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<ContextMenuProvider>
|
<ContextMenuProvider>
|
||||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
<Splash isReady={isReady && hasCheckedLanding}>
|
||||||
<VideoVolumeProvider>
|
<VideoVolumeProvider>
|
||||||
<ActiveVideoProvider>
|
<ActiveVideoProvider>
|
||||||
<Fragment
|
<Fragment
|
||||||
@@ -224,9 +224,9 @@ function App() {
|
|||||||
<DialogStateProvider>
|
<DialogStateProvider>
|
||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<StarterPackProvider>
|
<LandingProvider>
|
||||||
<InnerApp />
|
<InnerApp />
|
||||||
</StarterPackProvider>
|
</LandingProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
</DialogStateProvider>
|
</DialogStateProvider>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
type State,
|
type State,
|
||||||
} from '#/lib/routes/types'
|
} from '#/lib/routes/types'
|
||||||
import {bskyTitle} from '#/lib/strings/headings'
|
import {bskyTitle} from '#/lib/strings/headings'
|
||||||
|
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
|
||||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||||
@@ -81,6 +82,7 @@ import {MessagesScreen} from '#/screens/Messages/ChatList'
|
|||||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||||
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
|
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
|
||||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||||
|
import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
|
||||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||||
import {ModerationScreen} from '#/screens/Moderation'
|
import {ModerationScreen} from '#/screens/Moderation'
|
||||||
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
||||||
@@ -487,6 +489,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
|||||||
getComponent={() => MessagesConversationSettingsScreen}
|
getComponent={() => MessagesConversationSettingsScreen}
|
||||||
options={{title: title(msg`Group chat settings`), requireAuth: true}}
|
options={{title: title(msg`Group chat settings`), requireAuth: true}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="MessagesJoinRequests"
|
||||||
|
getComponent={() => MessagesJoinRequestsScreen}
|
||||||
|
options={{title: title(msg`Requests to join`), requireAuth: true}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="MessagesSettings"
|
name="MessagesSettings"
|
||||||
getComponent={() => MessagesSettingsScreen}
|
getComponent={() => MessagesSettingsScreen}
|
||||||
@@ -797,6 +804,19 @@ const LINKING = {
|
|||||||
return buildStateObject('Flat', 'Home', params)
|
return buildStateObject('Flat', 'Home', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
|
||||||
|
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
|
||||||
|
// path to Home so the dialog overlays Home instead of NotFound. On native,
|
||||||
|
// react-navigation strips the `bluesky://` prefix and passes the path
|
||||||
|
// without a leading slash, so normalize before matching.
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
|
if (CHAT_INVITE_CODE_REGEX.test(normalizedPath.split('?')[0])) {
|
||||||
|
if (IS_NATIVE) {
|
||||||
|
return buildStateObject('HomeTab', 'Home', params)
|
||||||
|
}
|
||||||
|
return buildStateObject('Flat', 'Home', params)
|
||||||
|
}
|
||||||
|
|
||||||
if (IS_NATIVE) {
|
if (IS_NATIVE) {
|
||||||
if (name === 'Search') {
|
if (name === 'Search') {
|
||||||
return buildStateObject('SearchTab', 'Search', params)
|
return buildStateObject('SearchTab', 'Search', params)
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export enum Features {
|
|||||||
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
||||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||||
ImageUploadsHighResolution = 'image_uploads:high_resolution',
|
|
||||||
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
|
|
||||||
GroupChatsEnable = 'group_chats:enable',
|
GroupChatsEnable = 'group_chats:enable',
|
||||||
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
||||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||||
|
|||||||
@@ -28,23 +28,29 @@ type Layout = {
|
|||||||
border?: boolean
|
border?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
|
||||||
animate?: boolean
|
|
||||||
profiles: bsky.profile.AnyProfileView[]
|
|
||||||
size?: number
|
|
||||||
moderationOpts?: ModerationOpts
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AvatarBubbles({
|
export function AvatarBubbles({
|
||||||
animate = false,
|
animate = false,
|
||||||
profiles: allProfiles,
|
profiles: allProfiles,
|
||||||
|
self = false,
|
||||||
size = 120,
|
size = 120,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
}: Props) {
|
}: {
|
||||||
|
animate?: boolean
|
||||||
|
profiles: bsky.profile.AnyProfileView[]
|
||||||
|
/**
|
||||||
|
* By default, when there are more than 2 profiles, the current user is
|
||||||
|
* filtered out (so you don't see yourself among your own group's members).
|
||||||
|
* Set this to `true` for cases where every passed profile should appear,
|
||||||
|
* e.g. an invite preview where the owner is meaningful regardless of viewer.
|
||||||
|
*/
|
||||||
|
self?: boolean
|
||||||
|
size?: number
|
||||||
|
moderationOpts?: ModerationOpts
|
||||||
|
}) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const profiles =
|
const profiles =
|
||||||
allProfiles.length > 2
|
!self && allProfiles.length > 2
|
||||||
? allProfiles.filter(p => p.did !== currentAccount?.did)
|
? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
|
||||||
: allProfiles
|
: allProfiles
|
||||||
const moderations = useMemo(() => {
|
const moderations = useMemo(() => {
|
||||||
if (!moderationOpts) return []
|
if (!moderationOpts) return []
|
||||||
|
|||||||
@@ -757,11 +757,11 @@ export function useSharedButtonTextStyles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (size === 'large') {
|
if (size === 'large') {
|
||||||
baseStyles.push(a.text_md, a.leading_snug, a.font_medium)
|
baseStyles.push(a.text_md, a.font_medium)
|
||||||
} else if (size === 'small') {
|
} else if (size === 'small') {
|
||||||
baseStyles.push(a.text_sm, a.leading_snug, a.font_medium)
|
baseStyles.push(a.text_sm, a.font_medium)
|
||||||
} else if (size === 'tiny') {
|
} else if (size === 'tiny') {
|
||||||
baseStyles.push(a.text_xs, a.leading_snug, a.font_semi_bold)
|
baseStyles.push(a.text_xs, a.font_semi_bold)
|
||||||
}
|
}
|
||||||
|
|
||||||
return flatten(baseStyles)
|
return flatten(baseStyles)
|
||||||
|
|||||||
@@ -230,7 +230,11 @@ function LightboxGallery({
|
|||||||
style={[
|
style={[
|
||||||
a.px_4xl,
|
a.px_4xl,
|
||||||
a.py_2xl,
|
a.py_2xl,
|
||||||
{backgroundColor: 'rgba(0, 0, 0, 0.45)'},
|
{
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
// @ts-expect-error web only
|
||||||
|
backdropFilter: 'blur(16px)',
|
||||||
|
},
|
||||||
delayedFadeInAnim,
|
delayedFadeInAnim,
|
||||||
]}>
|
]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import {useRef} from 'react'
|
import {useRef} from 'react'
|
||||||
import {
|
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
|
||||||
LayoutAnimation,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
View,
|
|
||||||
} from 'react-native'
|
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
import {BlurView} from 'expo-blur'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, platform, useTheme} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -37,36 +32,65 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
|||||||
{paddingBottom: insets.bottom + 8},
|
{paddingBottom: insets.bottom + 8},
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.mx_md, styles.altWrap]}>
|
<View style={[a.mx_md, styles.altWrap]}>
|
||||||
<ScrollView
|
<BlurView
|
||||||
scrollEnabled={isAltExpanded}
|
intensity={16}
|
||||||
onMomentumScrollBegin={() => {
|
tint="dark"
|
||||||
isMomentumScrolling.current = true
|
style={[
|
||||||
}}
|
// Tint kept over the blur so dense, text-heavy images stay
|
||||||
onMomentumScrollEnd={() => {
|
// readable. On Android the blur falls back to a flat overlay, so
|
||||||
isMomentumScrolling.current = false
|
// bump the opacity to keep the contrast the real blur provides
|
||||||
}}
|
// elsewhere.
|
||||||
contentContainerStyle={[a.px_md, a.py_sm]}>
|
platform({
|
||||||
<Pressable
|
ios: {backgroundColor: 'rgba(0, 0, 0, 0.5)'},
|
||||||
accessibilityRole="button"
|
android: {backgroundColor: 'rgba(0, 0, 0, 0.7)'},
|
||||||
accessibilityLabel={l`Expand alt text`}
|
}),
|
||||||
accessibilityHint=""
|
]}>
|
||||||
onPress={() => {
|
<ScrollView
|
||||||
if (isMomentumScrolling.current) return
|
scrollEnabled={isAltExpanded}
|
||||||
LayoutAnimation.configureNext({
|
onMomentumScrollBegin={() => {
|
||||||
duration: 450,
|
isMomentumScrolling.current = true
|
||||||
update: {type: 'spring', springDamping: 1},
|
}}
|
||||||
})
|
onMomentumScrollEnd={() => {
|
||||||
onToggleAltExpanded()
|
isMomentumScrolling.current = false
|
||||||
}}>
|
}}
|
||||||
<Text
|
contentContainerStyle={[a.px_md, a.py_sm]}>
|
||||||
emoji
|
<View
|
||||||
selectable
|
accessibilityRole="button"
|
||||||
style={[a.text_sm, {color: t.palette.white}]}
|
accessibilityLabel={l`Expand alt text`}
|
||||||
numberOfLines={isAltExpanded ? undefined : 3}>
|
accessibilityHint="">
|
||||||
{altText}
|
{/*
|
||||||
</Text>
|
* The press handlers must live on the Text itself, not on a
|
||||||
</Pressable>
|
* wrapping Pressable. Text selection is driven by the platform's
|
||||||
</ScrollView>
|
* native text view long-press (RN Text on Android, UITextView on
|
||||||
|
* iOS). A parent touchable consumes that long-press before the
|
||||||
|
* selectable Text can begin a selection - on Android this prevents
|
||||||
|
* selection entirely. Keeping onPress/onLongPress on the Text lets
|
||||||
|
* the same native node own both the tap-to-expand and the
|
||||||
|
* long-press-to-select. The empty onLongPress is intentional: it
|
||||||
|
* reserves the long-press for the OS selection gesture instead of
|
||||||
|
* firing the expand toggle. RN exposes no API to arbitrate tap vs
|
||||||
|
* native selection on a single node, so this is the supported
|
||||||
|
* workaround, and it behaves consistently on both platforms.
|
||||||
|
*/}
|
||||||
|
<Text
|
||||||
|
emoji
|
||||||
|
selectable
|
||||||
|
style={[a.text_sm, {color: t.palette.white}]}
|
||||||
|
numberOfLines={isAltExpanded ? undefined : 3}
|
||||||
|
onPress={() => {
|
||||||
|
if (isMomentumScrolling.current) return
|
||||||
|
LayoutAnimation.configureNext({
|
||||||
|
duration: 450,
|
||||||
|
update: {type: 'spring', springDamping: 1},
|
||||||
|
})
|
||||||
|
onToggleAltExpanded()
|
||||||
|
}}
|
||||||
|
onLongPress={() => {}}>
|
||||||
|
{altText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</BlurView>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -74,7 +98,6 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
altWrap: {
|
altWrap: {
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@react-navigation/native'
|
} from '@react-navigation/native'
|
||||||
|
|
||||||
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
||||||
|
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
|
||||||
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
|
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
|
||||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||||
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
|
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
|
||||||
@@ -19,6 +20,7 @@ import {shareUrl} from '#/lib/sharing'
|
|||||||
import {
|
import {
|
||||||
convertBskyAppUrlIfNeeded,
|
convertBskyAppUrlIfNeeded,
|
||||||
createProxiedUrl,
|
createProxiedUrl,
|
||||||
|
getChatInviteCodeFromUrl,
|
||||||
isBskyDownloadUrl,
|
isBskyDownloadUrl,
|
||||||
isExternalUrl,
|
isExternalUrl,
|
||||||
linkRequiresWarning,
|
linkRequiresWarning,
|
||||||
@@ -130,6 +132,7 @@ export function useLink({
|
|||||||
const {closeModal} = useModalControls()
|
const {closeModal} = useModalControls()
|
||||||
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
||||||
const openLink = useOpenLink()
|
const openLink = useOpenLink()
|
||||||
|
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||||
|
|
||||||
const onPress = useCallback(
|
const onPress = useCallback(
|
||||||
(e: GestureResponderEvent) => {
|
(e: GestureResponderEvent) => {
|
||||||
@@ -148,6 +151,12 @@ export function useLink({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chatInviteCode = getChatInviteCodeFromUrl(href)
|
||||||
|
if (chatInviteCode) {
|
||||||
|
groupChatJoinIntent(chatInviteCode, href)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (requiresWarning) {
|
if (requiresWarning) {
|
||||||
linkWarningDialogControl.open({
|
linkWarningDialogControl.open({
|
||||||
displayText,
|
displayText,
|
||||||
@@ -228,6 +237,7 @@ export function useLink({
|
|||||||
overridePresentation,
|
overridePresentation,
|
||||||
shouldProxy,
|
shouldProxy,
|
||||||
linkWarningDialogControl,
|
linkWarningDialogControl,
|
||||||
|
groupChatJoinIntent,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {shareImageModal} from '#/lib/media/manip'
|
||||||
|
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
|
||||||
import {isGifEmbed} from '#/lib/strings/embed-player'
|
import {isGifEmbed} from '#/lib/strings/embed-player'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||||
|
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
|
import * as PeekMenu from '#/components/PeekMenu'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
import * as bsky from '#/types/bsky'
|
import * as bsky from '#/types/bsky'
|
||||||
@@ -16,9 +21,11 @@ import * as bsky from '#/types/bsky'
|
|||||||
export function Embed({
|
export function Embed({
|
||||||
embed,
|
embed,
|
||||||
style,
|
style,
|
||||||
|
peekable = false,
|
||||||
}: {
|
}: {
|
||||||
embed: AppBskyFeedDefs.PostView['embed']
|
embed: AppBskyFeedDefs.PostView['embed']
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
peekable?: boolean
|
||||||
}) {
|
}) {
|
||||||
const e = bsky.post.parseEmbed(embed)
|
const e = bsky.post.parseEmbed(embed)
|
||||||
|
|
||||||
@@ -27,13 +34,17 @@ export function Embed({
|
|||||||
if (e.type === 'images') {
|
if (e.type === 'images') {
|
||||||
return (
|
return (
|
||||||
<Outer style={style}>
|
<Outer style={style}>
|
||||||
{e.view.images.map(image => (
|
{e.view.images.map(image =>
|
||||||
<ImageItem
|
peekable ? (
|
||||||
key={image.thumb}
|
<PeekableImageItem key={image.thumb} image={image} />
|
||||||
thumbnail={image.thumb}
|
) : (
|
||||||
alt={image.alt}
|
<ImageItem
|
||||||
/>
|
key={image.thumb}
|
||||||
))}
|
thumbnail={image.thumb}
|
||||||
|
alt={image.alt}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</Outer>
|
</Outer>
|
||||||
)
|
)
|
||||||
} else if (e.type === 'link') {
|
} else if (e.type === 'link') {
|
||||||
@@ -156,6 +167,45 @@ export function VideoItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PeekableImageItem({image}: {image: AppBskyEmbedImages.ViewImage}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const saveImage = useSaveImageToMediaLibrary()
|
||||||
|
|
||||||
|
const aspect =
|
||||||
|
image.aspectRatio && image.aspectRatio.height > 0
|
||||||
|
? image.aspectRatio.width / image.aspectRatio.height
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PeekMenu.Root style={[a.flex_1, {maxWidth: 100}]}>
|
||||||
|
<PeekMenu.Trigger
|
||||||
|
preview={{
|
||||||
|
type: 'image',
|
||||||
|
uri: image.fullsize,
|
||||||
|
thumbUri: image.thumb,
|
||||||
|
aspectRatio: aspect && aspect > 0 ? aspect : 1,
|
||||||
|
}}
|
||||||
|
borderRadius={tokens.borderRadius.xs}>
|
||||||
|
<ImageItem thumbnail={image.thumb} alt={image.alt} />
|
||||||
|
</PeekMenu.Trigger>
|
||||||
|
<PeekMenu.Menu>
|
||||||
|
<PeekMenu.MenuItem
|
||||||
|
id="save"
|
||||||
|
onSelect={() => void saveImage(image.fullsize)}>
|
||||||
|
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
|
||||||
|
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
|
||||||
|
</PeekMenu.MenuItem>
|
||||||
|
<PeekMenu.MenuItem
|
||||||
|
id="share"
|
||||||
|
onSelect={() => void shareImageModal({uri: image.fullsize})}>
|
||||||
|
<PeekMenu.MenuItemIcon icon={ShareIcon} />
|
||||||
|
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
|
||||||
|
</PeekMenu.MenuItem>
|
||||||
|
</PeekMenu.Menu>
|
||||||
|
</PeekMenu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
altContainer: {
|
altContainer: {
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from '@bsky.app/peek-menu'
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {type ReactNode} from 'react'
|
||||||
|
import {type StyleProp, type ViewStyle} from 'react-native'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {shareImageModal} from '#/lib/media/manip'
|
||||||
|
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
|
||||||
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||||
|
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
||||||
|
import * as PeekMenu from '#/components/PeekMenu'
|
||||||
|
import {IS_IOS} from '#/env'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an image embed with the iOS peek-and-menu interaction. On non-iOS
|
||||||
|
* platforms this renders children unchanged.
|
||||||
|
*
|
||||||
|
* The aspect ratio is consumed by the native side to size the preview
|
||||||
|
* viewController correctly — which is what makes the lift animation clean
|
||||||
|
* for portrait/panorama images.
|
||||||
|
*/
|
||||||
|
export function ImageContextMenu({
|
||||||
|
fullsizeUri,
|
||||||
|
thumbUri,
|
||||||
|
aspectRatio,
|
||||||
|
borderRadius,
|
||||||
|
onPreviewPress,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
fullsizeUri: string
|
||||||
|
/** Thumbnail URL. Used as an instant placeholder in the native preview
|
||||||
|
* while the fullsize loads, so there's no black flash on first peek. */
|
||||||
|
thumbUri?: string
|
||||||
|
/** width / height; defaults to 1 if missing. */
|
||||||
|
aspectRatio: number | undefined
|
||||||
|
borderRadius?: number
|
||||||
|
onPreviewPress?: () => void
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const saveImage = useSaveImageToMediaLibrary()
|
||||||
|
|
||||||
|
if (!IS_IOS) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
void saveImage(fullsizeUri)
|
||||||
|
}
|
||||||
|
const handleShare = () => {
|
||||||
|
void shareImageModal({uri: fullsizeUri})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PeekMenu.Root style={style}>
|
||||||
|
<PeekMenu.Trigger
|
||||||
|
preview={{
|
||||||
|
type: 'image',
|
||||||
|
uri: fullsizeUri,
|
||||||
|
thumbUri,
|
||||||
|
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
|
||||||
|
}}
|
||||||
|
borderRadius={borderRadius}
|
||||||
|
onPreviewPress={onPreviewPress}>
|
||||||
|
{children}
|
||||||
|
</PeekMenu.Trigger>
|
||||||
|
<PeekMenu.Menu>
|
||||||
|
<PeekMenu.MenuItem id="save" onSelect={handleSave}>
|
||||||
|
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
|
||||||
|
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
|
||||||
|
</PeekMenu.MenuItem>
|
||||||
|
<PeekMenu.MenuItem id="share" onSelect={handleShare}>
|
||||||
|
<PeekMenu.MenuItemIcon icon={ShareIcon} />
|
||||||
|
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
|
||||||
|
</PeekMenu.MenuItem>
|
||||||
|
</PeekMenu.Menu>
|
||||||
|
</PeekMenu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {useRef} from 'react'
|
||||||
import {InteractionManager, View} from 'react-native'
|
import {InteractionManager, View} from 'react-native'
|
||||||
import {type AnimatedRef} from 'react-native-reanimated'
|
import {type AnimatedRef} from 'react-native-reanimated'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
@@ -8,6 +9,7 @@ import {Gallery} from '#/components/images/Gallery'
|
|||||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||||
import {useLightboxControls} from '#/components/Lightbox/state'
|
import {useLightboxControls} from '#/components/Lightbox/state'
|
||||||
import {type Dimensions} from '#/components/Lightbox/types'
|
import {type Dimensions} from '#/components/Lightbox/types'
|
||||||
|
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {type EmbedType} from '#/types/bsky/post'
|
import {type EmbedType} from '#/types/bsky/post'
|
||||||
@@ -24,6 +26,11 @@ export function ImageEmbed({
|
|||||||
const {images} = embed.view
|
const {images} = embed.view
|
||||||
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||||
|
|
||||||
|
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
|
||||||
|
// ref + dims that a tap would — keeps the lightbox's return animation intact.
|
||||||
|
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
|
||||||
|
const singleDimsRef = useRef<Dimensions | null>(null)
|
||||||
|
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
const items = images.map(img => ({
|
const items = images.map(img => ({
|
||||||
uri: img.fullsize,
|
uri: img.fullsize,
|
||||||
@@ -59,21 +66,45 @@ export function ImageEmbed({
|
|||||||
|
|
||||||
if (images.length === 1) {
|
if (images.length === 1) {
|
||||||
const image = images[0]
|
const image = images[0]
|
||||||
|
const aspect =
|
||||||
|
image.aspectRatio && image.aspectRatio.height > 0
|
||||||
|
? image.aspectRatio.width / image.aspectRatio.height
|
||||||
|
: undefined
|
||||||
|
const openFromSingle = () => {
|
||||||
|
if (singleContainerRef.current) {
|
||||||
|
onPress(0, [singleContainerRef.current], [singleDimsRef.current])
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<View style={[a.mt_sm, rest.style]}>
|
<View style={[a.mt_sm, rest.style]}>
|
||||||
<AutoSizedImage
|
<ImageContextMenu
|
||||||
crop={
|
fullsizeUri={image.fullsize}
|
||||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
thumbUri={image.thumb}
|
||||||
? 'none'
|
aspectRatio={aspect}
|
||||||
: rest.isWithinQuote
|
borderRadius={tokens.borderRadius.md}
|
||||||
? 'square'
|
onPreviewPress={openFromSingle}>
|
||||||
: 'constrained'
|
<AutoSizedImage
|
||||||
}
|
crop={
|
||||||
image={image}
|
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||||
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
|
? 'none'
|
||||||
onPressIn={() => onPressIn(0)}
|
: rest.isWithinQuote
|
||||||
hideBadge={rest.isWithinQuote}
|
? 'square'
|
||||||
/>
|
: 'constrained'
|
||||||
|
}
|
||||||
|
image={image}
|
||||||
|
onContainerRef={ref => {
|
||||||
|
singleContainerRef.current = ref
|
||||||
|
}}
|
||||||
|
onDimsChange={dims => {
|
||||||
|
singleDimsRef.current = dims
|
||||||
|
}}
|
||||||
|
onPress={(containerRef, dims) =>
|
||||||
|
onPress(0, [containerRef], [dims])
|
||||||
|
}
|
||||||
|
onPressIn={() => onPressIn(0)}
|
||||||
|
hideBadge={rest.isWithinQuote}
|
||||||
|
/>
|
||||||
|
</ImageContextMenu>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export function Basic({
|
|||||||
cancelButtonCta,
|
cancelButtonCta,
|
||||||
confirmButtonCta,
|
confirmButtonCta,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
onClose,
|
||||||
confirmButtonColor,
|
confirmButtonColor,
|
||||||
showCancel = true,
|
showCancel = true,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
@@ -240,11 +241,12 @@ export function Basic({
|
|||||||
* should NOT close the dialog as a side effect of this method.
|
* should NOT close the dialog as a side effect of this method.
|
||||||
*/
|
*/
|
||||||
onConfirm: (e: GestureResponderEvent) => void
|
onConfirm: (e: GestureResponderEvent) => void
|
||||||
|
onClose?: () => void
|
||||||
confirmButtonColor?: ButtonColor
|
confirmButtonColor?: ButtonColor
|
||||||
showCancel?: boolean
|
showCancel?: boolean
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<Outer control={control} testID="confirmModal">
|
<Outer control={control} testID="confirmModal" onClose={onClose}>
|
||||||
<Content>
|
<Content>
|
||||||
<TitleText>{title}</TitleText>
|
<TitleText>{title}</TitleText>
|
||||||
{description && <DescriptionText>{description}</DescriptionText>}
|
{description && <DescriptionText>{description}</DescriptionText>}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function RichText({
|
|||||||
}
|
}
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
const plainStyles = [a.leading_snug, style]
|
const plainStyles = style
|
||||||
const interactiveStyles = [plainStyles, interactiveStyle]
|
const interactiveStyles = [plainStyles, interactiveStyle]
|
||||||
|
|
||||||
const {text, facets} = richText
|
const {text, facets} = richText
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
type UninheritableButtonProps,
|
type UninheritableButtonProps,
|
||||||
} from '#/components/Button'
|
} from '#/components/Button'
|
||||||
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
|
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
CircleInfo_Stroke2_Corner0_Rounded as CircleInfo,
|
||||||
|
CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon,
|
||||||
|
} from '#/components/icons/CircleInfo'
|
||||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||||
import {dismiss} from '#/components/Toast/sonner'
|
import {dismiss} from '#/components/Toast/sonner'
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function Text({
|
|||||||
[
|
[
|
||||||
a.text_sm,
|
a.text_sm,
|
||||||
t.atoms.text,
|
t.atoms.text,
|
||||||
|
a.leading_snug,
|
||||||
web(numberOfLines === 1 && numberOfLinesClippingFix),
|
web(numberOfLines === 1 && numberOfLinesClippingFix),
|
||||||
style,
|
style,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export function AddMembersFlow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchText === '') {
|
if (searchText === '' && _items.length > 0) {
|
||||||
_items.unshift({
|
_items.unshift({
|
||||||
type: 'label',
|
type: 'label',
|
||||||
key: 'suggested',
|
key: 'suggested',
|
||||||
|
|||||||
@@ -33,14 +33,19 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({
|
|||||||
control,
|
control,
|
||||||
params,
|
params,
|
||||||
currentScreen,
|
currentScreen,
|
||||||
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
params: ReportDialogParams
|
params: ReportDialogParams
|
||||||
currentScreen: 'list' | 'conversation'
|
currentScreen: 'list' | 'conversation'
|
||||||
|
onClose?: () => void
|
||||||
}): React.ReactNode {
|
}): React.ReactNode {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
return (
|
return (
|
||||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
<Dialog.Outer
|
||||||
|
control={control}
|
||||||
|
onClose={onClose}
|
||||||
|
nativeOptions={{preventExpansion: true}}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={l`Would you like to block this user and/or delete this conversation?`}
|
label={l`Would you like to block this user and/or delete this conversation?`}
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export function InitiateChatFlow({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
searchText === '' &&
|
searchText === '' &&
|
||||||
|
_items.length > 0 &&
|
||||||
(chatState === ChatState.NEW_CHAT ||
|
(chatState === ChatState.NEW_CHAT ||
|
||||||
chatState === ChatState.NEW_GROUP_CHAT)
|
chatState === ChatState.NEW_GROUP_CHAT)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {memo, useCallback} from 'react'
|
import {memo, useCallback} from 'react'
|
||||||
import {LayoutAnimation, Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
import * as Clipboard from 'expo-clipboard'
|
import * as Clipboard from 'expo-clipboard'
|
||||||
import {
|
import {
|
||||||
type ChatBskyConvoDefs,
|
type ChatBskyConvoDefs,
|
||||||
@@ -7,26 +7,21 @@ import {
|
|||||||
RichText,
|
RichText,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {useConvoActive} from '#/state/messages/convo'
|
import {useConvoActive} from '#/state/messages/convo'
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import * as ContextMenu from '#/components/ContextMenu'
|
import * as ContextMenu from '#/components/ContextMenu'
|
||||||
import {type TriggerProps} from '#/components/ContextMenu/types'
|
import {type TriggerProps} from '#/components/ContextMenu/types'
|
||||||
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
|
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||||
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||||
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
|
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||||
import {ReportDialog} from '#/components/moderation/ReportDialog'
|
|
||||||
import * as Prompt from '#/components/Prompt'
|
|
||||||
import {usePromptControl} from '#/components/Prompt'
|
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
@@ -48,11 +43,8 @@ export let MessageContextMenu = ({
|
|||||||
const {t: l, i18n} = useLingui()
|
const {t: l, i18n} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const convo = useConvoActive()
|
const convo = useConvoActive()
|
||||||
const deleteControl = usePromptControl()
|
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
|
||||||
const reportControl = usePromptControl()
|
|
||||||
const blockOrDeleteControl = usePromptControl()
|
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const translate = useGoogleTranslate()
|
const translate = useGoogleTranslate()
|
||||||
|
|
||||||
@@ -93,14 +85,6 @@ export let MessageContextMenu = ({
|
|||||||
})
|
})
|
||||||
}, [ax, langPrefs.primaryLanguage, message.text, translate])
|
}, [ax, langPrefs.primaryLanguage, message.text, translate])
|
||||||
|
|
||||||
const onDelete = useCallback(() => {
|
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
|
||||||
convo
|
|
||||||
.deleteMessage(message.id)
|
|
||||||
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
|
|
||||||
.catch(() => Toast.show(l`Failed to delete message`))
|
|
||||||
}, [l, convo, message.id])
|
|
||||||
|
|
||||||
const onEmojiSelect = useCallback(
|
const onEmojiSelect = useCallback(
|
||||||
(emoji: string) => {
|
(emoji: string) => {
|
||||||
if (
|
if (
|
||||||
@@ -128,104 +112,72 @@ export let MessageContextMenu = ({
|
|||||||
const sender = senderProfile
|
const sender = senderProfile
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ContextMenu.Root>
|
||||||
<ContextMenu.Root>
|
{IS_NATIVE && reactionsAvailable && (
|
||||||
{IS_NATIVE && reactionsAvailable && (
|
<ContextMenu.AuxiliaryView
|
||||||
<ContextMenu.AuxiliaryView
|
|
||||||
align={isFromSelf ? 'right' : 'left'}
|
|
||||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
|
||||||
<EmojiReactionPicker
|
|
||||||
message={message}
|
|
||||||
onEmojiSelect={onEmojiSelect}
|
|
||||||
/>
|
|
||||||
</ContextMenu.AuxiliaryView>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ContextMenu.Trigger
|
|
||||||
label={l`Message options`}
|
|
||||||
contentLabel={l`Message from @${
|
|
||||||
sender?.handle ?? 'unknown' // should always be defined
|
|
||||||
}: ${message.text}`}>
|
|
||||||
{children}
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
|
|
||||||
<ContextMenu.Outer
|
|
||||||
align={isFromSelf ? 'right' : 'left'}
|
align={isFromSelf ? 'right' : 'left'}
|
||||||
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
|
|
||||||
timeStyle: 'short',
|
|
||||||
})}`}
|
|
||||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||||
{message.text.length > 0 && (
|
<EmojiReactionPicker
|
||||||
<>
|
message={message}
|
||||||
<ContextMenu.Item
|
onEmojiSelect={onEmojiSelect}
|
||||||
testID="messageDropdownTranslateBtn"
|
/>
|
||||||
label={l`Translate`}
|
</ContextMenu.AuxiliaryView>
|
||||||
onPress={onPressTranslateMessage}>
|
)}
|
||||||
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
|
|
||||||
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
|
<ContextMenu.Trigger
|
||||||
</ContextMenu.Item>
|
label={l`Message options`}
|
||||||
<ContextMenu.Item
|
contentLabel={l`Message from @${
|
||||||
testID="messageDropdownCopyBtn"
|
sender?.handle ?? 'unknown' // should always be defined
|
||||||
label={l`Copy message text`}
|
}: ${message.text}`}>
|
||||||
onPress={onCopyMessage}>
|
{children}
|
||||||
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.ItemText>
|
|
||||||
{l`Copy message text`}
|
<ContextMenu.Outer
|
||||||
</ContextMenu.ItemText>
|
align={isFromSelf ? 'right' : 'left'}
|
||||||
</ContextMenu.Item>
|
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
|
||||||
</>
|
timeStyle: 'short',
|
||||||
)}
|
})}`}
|
||||||
|
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||||
|
{message.text.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ContextMenu.Item
|
||||||
|
testID="messageDropdownTranslateBtn"
|
||||||
|
label={l`Translate`}
|
||||||
|
onPress={onPressTranslateMessage}>
|
||||||
|
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
|
||||||
|
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Item
|
||||||
|
testID="messageDropdownCopyBtn"
|
||||||
|
label={l`Copy message text`}
|
||||||
|
onPress={onCopyMessage}>
|
||||||
|
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
|
||||||
|
<ContextMenu.ItemText>
|
||||||
|
{l`Copy message text`}
|
||||||
|
</ContextMenu.ItemText>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ContextMenu.Item
|
||||||
|
destructive
|
||||||
|
testID="messageDropdownDeleteBtn"
|
||||||
|
label={l`Delete message for me`}
|
||||||
|
onPress={() => openDeleteMessage(message)}>
|
||||||
|
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
|
||||||
|
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
{!isFromSelf && (
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
destructive
|
destructive
|
||||||
testID="messageDropdownDeleteBtn"
|
testID="messageDropdownReportBtn"
|
||||||
label={l`Delete message for me`}
|
label={l`Report message`}
|
||||||
onPress={() => deleteControl.open()}>
|
onPress={() => openReportMessage(message, senderProfile)}>
|
||||||
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
|
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
|
||||||
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
|
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{!isFromSelf && (
|
)}
|
||||||
<ContextMenu.Item
|
</ContextMenu.Outer>
|
||||||
destructive
|
</ContextMenu.Root>
|
||||||
testID="messageDropdownReportBtn"
|
|
||||||
label={l`Report message`}
|
|
||||||
onPress={() => reportControl.open()}>
|
|
||||||
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
|
|
||||||
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
)}
|
|
||||||
</ContextMenu.Outer>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
<ReportDialog
|
|
||||||
control={reportControl}
|
|
||||||
subject={{
|
|
||||||
view: 'message',
|
|
||||||
convoId: convo.convo.view.id,
|
|
||||||
message,
|
|
||||||
}}
|
|
||||||
onAfterSubmit={() => {
|
|
||||||
if (sender) {
|
|
||||||
unstableCacheProfileView(queryClient, sender)
|
|
||||||
}
|
|
||||||
blockOrDeleteControl.open()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<AfterReportDialog
|
|
||||||
control={blockOrDeleteControl}
|
|
||||||
currentScreen="conversation"
|
|
||||||
params={{
|
|
||||||
convoId: convo.convo.view.id,
|
|
||||||
did: message.sender.did,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Prompt.Basic
|
|
||||||
control={deleteControl}
|
|
||||||
title={l`Delete message`}
|
|
||||||
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
|
|
||||||
confirmButtonCta={l`Delete`}
|
|
||||||
confirmButtonColor="negative"
|
|
||||||
onConfirm={onDelete}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageContextMenu = memo(MessageContextMenu)
|
MessageContextMenu = memo(MessageContextMenu)
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ import {useSession} from '#/state/session'
|
|||||||
import {atoms as a, native, platform, useTheme} from '#/alf'
|
import {atoms as a, native, platform, useTheme} from '#/alf'
|
||||||
import {isOnlyEmoji} from '#/alf/typography'
|
import {isOnlyEmoji} from '#/alf/typography'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
|
||||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||||
|
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||||
import {InlineLinkText, Link} from '#/components/Link'
|
import {InlineLinkText, Link} from '#/components/Link'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
@@ -49,7 +49,7 @@ import {RichText} from '#/components/RichText'
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {DateDivider} from './DateDivider'
|
import {DateDivider} from './DateDivider'
|
||||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||||
import {ReactionsDialog} from './ReactionsDialog'
|
import {groupReactions} from './ReactionsDialog'
|
||||||
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
|
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
|
||||||
|
|
||||||
const AVATAR_SIZE = 28
|
const AVATAR_SIZE = 28
|
||||||
@@ -118,7 +118,7 @@ let MessageItem = ({
|
|||||||
const {message} = item
|
const {message} = item
|
||||||
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
||||||
|
|
||||||
const reactionsControl = useDialogControl()
|
const {openReactions} = useMessageDialogs()
|
||||||
|
|
||||||
const isPending = item.type === 'pending-message'
|
const isPending = item.type === 'pending-message'
|
||||||
|
|
||||||
@@ -243,34 +243,10 @@ let MessageItem = ({
|
|||||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||||
)
|
)
|
||||||
|
|
||||||
const groupedReactions = useMemo(() => {
|
const groupedReactions = useMemo(
|
||||||
const reactions = message.reactions ?? []
|
() => groupReactions(message.reactions),
|
||||||
const grouped = new Map<
|
[message.reactions],
|
||||||
string,
|
)
|
||||||
{
|
|
||||||
key: string
|
|
||||||
value: string
|
|
||||||
senders: ChatBskyConvoDefs.ReactionViewSender[]
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
>()
|
|
||||||
for (const reaction of reactions) {
|
|
||||||
if (!reaction) continue
|
|
||||||
const existing = grouped.get(reaction.value)
|
|
||||||
if (existing) {
|
|
||||||
existing.senders.push(reaction.sender)
|
|
||||||
existing.count++
|
|
||||||
} else {
|
|
||||||
grouped.set(reaction.value, {
|
|
||||||
key: reaction.value,
|
|
||||||
value: reaction.value,
|
|
||||||
senders: [reaction.sender],
|
|
||||||
count: 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(grouped.values())
|
|
||||||
}, [message.reactions])
|
|
||||||
|
|
||||||
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
|
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
|
||||||
|
|
||||||
@@ -336,7 +312,7 @@ let MessageItem = ({
|
|||||||
transform: [{translateY: -8}],
|
transform: [{translateY: -8}],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onPress={isGroupChat ? reactionsControl.open : undefined}>
|
onPress={isGroupChat ? () => openReactions(message) : undefined}>
|
||||||
{groupedReactions.map(group => (
|
{groupedReactions.map(group => (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={native(ZoomIn.springify(200).delay(400))}
|
entering={native(ZoomIn.springify(200).delay(400))}
|
||||||
@@ -377,13 +353,6 @@ let MessageItem = ({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<ReactionsDialog
|
|
||||||
control={reactionsControl}
|
|
||||||
relatedProfiles={relatedProfiles}
|
|
||||||
message={message}
|
|
||||||
reactions={message.reactions}
|
|
||||||
groupedReactions={groupedReactions}
|
|
||||||
/>
|
|
||||||
</LayoutAnimationConfig>
|
</LayoutAnimationConfig>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {LayoutAnimation} from 'react-native'
|
||||||
|
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useConvoActive} from '#/state/messages/convo'
|
||||||
|
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
|
||||||
|
import {ReactionsDialog} from '#/components/dms/ReactionsDialog'
|
||||||
|
import {ReportDialog} from '#/components/moderation/ReportDialog'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {usePromptControl} from '#/components/Prompt'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
type MessageDialogsContextType = {
|
||||||
|
openDeleteMessage: (message: ChatBskyConvoDefs.MessageView) => void
|
||||||
|
openReportMessage: (
|
||||||
|
message: ChatBskyConvoDefs.MessageView,
|
||||||
|
senderProfile: bsky.profile.AnyProfileView | undefined,
|
||||||
|
) => void
|
||||||
|
openReactions: (message: ChatBskyConvoDefs.MessageView) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = createContext<MessageDialogsContextType | null>(null)
|
||||||
|
|
||||||
|
export function useMessageDialogs() {
|
||||||
|
const ctx = useContext(Context)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useMessageDialogs must be used within a MessageOverlays')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageOverlays({children}: {children: React.ReactNode}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const convo = useConvoActive()
|
||||||
|
|
||||||
|
const deleteControl = usePromptControl()
|
||||||
|
const reportControl = usePromptControl()
|
||||||
|
const afterReportControl = usePromptControl()
|
||||||
|
const reactionsControl = useDialogControl()
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] =
|
||||||
|
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||||
|
const [reportTarget, setReportTarget] = useState<{
|
||||||
|
message: ChatBskyConvoDefs.MessageView
|
||||||
|
senderProfile: bsky.profile.AnyProfileView | undefined
|
||||||
|
} | null>(null)
|
||||||
|
const [afterReportTarget, setAfterReportTarget] =
|
||||||
|
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||||
|
const [reactionsTarget, setReactionsTarget] =
|
||||||
|
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||||
|
|
||||||
|
const openDeleteMessage = useCallback(
|
||||||
|
(message: ChatBskyConvoDefs.MessageView) => {
|
||||||
|
setDeleteTarget(message)
|
||||||
|
deleteControl.open()
|
||||||
|
},
|
||||||
|
[deleteControl],
|
||||||
|
)
|
||||||
|
|
||||||
|
const openReportMessage = useCallback(
|
||||||
|
(
|
||||||
|
message: ChatBskyConvoDefs.MessageView,
|
||||||
|
senderProfile: bsky.profile.AnyProfileView | undefined,
|
||||||
|
) => {
|
||||||
|
setReportTarget({message, senderProfile})
|
||||||
|
reportControl.open()
|
||||||
|
},
|
||||||
|
[reportControl],
|
||||||
|
)
|
||||||
|
|
||||||
|
const openReactions = useCallback(
|
||||||
|
(message: ChatBskyConvoDefs.MessageView) => {
|
||||||
|
setReactionsTarget(message)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// These dialogs are conditionally mounted, so we can't open them in the same
|
||||||
|
// tick that we set their targets - the control refs aren't attached yet. Open
|
||||||
|
// in an effect after the dialog has mounted.
|
||||||
|
useEffect(() => {
|
||||||
|
if (reactionsTarget) {
|
||||||
|
reactionsControl.open()
|
||||||
|
}
|
||||||
|
}, [reactionsTarget, reactionsControl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (afterReportTarget) {
|
||||||
|
afterReportControl.open()
|
||||||
|
}
|
||||||
|
}, [afterReportTarget, afterReportControl])
|
||||||
|
|
||||||
|
const onConfirmDelete = useCallback(() => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
|
convo
|
||||||
|
.deleteMessage(deleteTarget.id)
|
||||||
|
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
|
||||||
|
.catch(() => Toast.show(l`Failed to delete message`))
|
||||||
|
}, [l, convo, deleteTarget])
|
||||||
|
|
||||||
|
const onAfterReportSubmit = useCallback(() => {
|
||||||
|
if (!reportTarget) return
|
||||||
|
if (reportTarget.senderProfile) {
|
||||||
|
unstableCacheProfileView(queryClient, reportTarget.senderProfile)
|
||||||
|
}
|
||||||
|
setAfterReportTarget(reportTarget.message)
|
||||||
|
}, [queryClient, reportTarget])
|
||||||
|
|
||||||
|
const ctx = useMemo<MessageDialogsContextType>(
|
||||||
|
() => ({openDeleteMessage, openReportMessage, openReactions}),
|
||||||
|
[openDeleteMessage, openReportMessage, openReactions],
|
||||||
|
)
|
||||||
|
|
||||||
|
const reportSubject = reportTarget
|
||||||
|
? ({
|
||||||
|
view: 'message',
|
||||||
|
convoId: convo.convo.view.id,
|
||||||
|
message: reportTarget.message,
|
||||||
|
} as const)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={ctx}>
|
||||||
|
{children}
|
||||||
|
<ReportDialog
|
||||||
|
control={reportControl}
|
||||||
|
subject={reportSubject}
|
||||||
|
onAfterSubmit={onAfterReportSubmit}
|
||||||
|
onClose={() => setReportTarget(null)}
|
||||||
|
/>
|
||||||
|
{afterReportTarget && (
|
||||||
|
<AfterReportDialog
|
||||||
|
control={afterReportControl}
|
||||||
|
currentScreen="conversation"
|
||||||
|
params={{
|
||||||
|
convoId: convo.convo.view.id,
|
||||||
|
did: afterReportTarget.sender.did,
|
||||||
|
}}
|
||||||
|
onClose={() => setAfterReportTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{reactionsTarget && (
|
||||||
|
<ReactionsDialog
|
||||||
|
control={reactionsControl}
|
||||||
|
relatedProfiles={convo.relatedProfiles}
|
||||||
|
message={reactionsTarget}
|
||||||
|
onClose={() => setReactionsTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Prompt.Basic
|
||||||
|
control={deleteControl}
|
||||||
|
title={l`Delete message`}
|
||||||
|
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
|
||||||
|
confirmButtonCta={l`Delete`}
|
||||||
|
confirmButtonColor="negative"
|
||||||
|
onConfirm={onConfirmDelete}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useRef, useState} from 'react'
|
import {useMemo, useRef, useState} from 'react'
|
||||||
import {
|
import {
|
||||||
LayoutAnimation,
|
LayoutAnimation,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -37,14 +37,12 @@ export function ReactionsDialog({
|
|||||||
control,
|
control,
|
||||||
relatedProfiles,
|
relatedProfiles,
|
||||||
message,
|
message,
|
||||||
reactions,
|
onClose,
|
||||||
groupedReactions,
|
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
onClose?: () => void
|
||||||
groupedReactions?: Reaction[]
|
|
||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
@@ -54,6 +52,9 @@ export function ReactionsDialog({
|
|||||||
|
|
||||||
const [selected, setSelected] = useState('all')
|
const [selected, setSelected] = useState('all')
|
||||||
|
|
||||||
|
const reactions = message.reactions
|
||||||
|
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
|
||||||
|
|
||||||
const filteredReactions = reactions?.filter(
|
const filteredReactions = reactions?.filter(
|
||||||
r => selected === 'all' || r.value === selected,
|
r => selected === 'all' || r.value === selected,
|
||||||
)
|
)
|
||||||
@@ -78,7 +79,10 @@ export function ReactionsDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog.Outer
|
<Dialog.Outer
|
||||||
control={control}
|
control={control}
|
||||||
onClose={() => setSelected('all')}
|
onClose={() => {
|
||||||
|
setSelected('all')
|
||||||
|
onClose?.()
|
||||||
|
}}
|
||||||
nativeOptions={{
|
nativeOptions={{
|
||||||
preventExpansion: true,
|
preventExpansion: true,
|
||||||
minHeight: screenHeight / 2,
|
minHeight: screenHeight / 2,
|
||||||
@@ -388,3 +392,25 @@ function ReactionTab({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function groupReactions(
|
||||||
|
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
|
||||||
|
): Reaction[] {
|
||||||
|
const grouped = new Map<string, Reaction>()
|
||||||
|
for (const reaction of reactions ?? []) {
|
||||||
|
if (!reaction) continue
|
||||||
|
const existing = grouped.get(reaction.value)
|
||||||
|
if (existing) {
|
||||||
|
existing.senders.push(reaction.sender)
|
||||||
|
existing.count++
|
||||||
|
} else {
|
||||||
|
grouped.set(reaction.value, {
|
||||||
|
key: reaction.value,
|
||||||
|
value: reaction.value,
|
||||||
|
senders: [reaction.sender],
|
||||||
|
count: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(grouped.values())
|
||||||
|
}
|
||||||
|
|||||||
+28
-4
@@ -1,22 +1,45 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
|
import * as Linking from 'expo-linking'
|
||||||
|
|
||||||
|
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
||||||
import {
|
import {
|
||||||
createStarterPackLinkFromAndroidReferrer,
|
createStarterPackLinkFromAndroidReferrer,
|
||||||
httpStarterPackUriToAtUri,
|
httpStarterPackUriToAtUri,
|
||||||
} from '#/lib/strings/starter-pack'
|
} from '#/lib/strings/starter-pack'
|
||||||
|
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
|
||||||
import {useHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
import {useHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
||||||
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
|
import {
|
||||||
|
useSetActiveLanding,
|
||||||
|
useSetActiveStarterPack,
|
||||||
|
} from '#/state/shell/landing'
|
||||||
import {IS_ANDROID} from '#/env'
|
import {IS_ANDROID} from '#/env'
|
||||||
import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
|
import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
export function useStarterPackEntry() {
|
export function useLandingEntry() {
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
const setActiveStarterPack = useSetActiveStarterPack()
|
const setActiveStarterPack = useSetActiveStarterPack()
|
||||||
|
const setActiveLanding = useSetActiveLanding()
|
||||||
const hasCheckedForStarterPack = useHasCheckedForStarterPack()
|
const hasCheckedForStarterPack = useHasCheckedForStarterPack()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ready) return
|
if (ready) return
|
||||||
|
|
||||||
|
// Check for group chat invite link from the initial deep link URL
|
||||||
|
const linkingUrl = Linking.getLinkingURL()
|
||||||
|
if (linkingUrl) {
|
||||||
|
const urlp = parseLinkingUrl(linkingUrl)
|
||||||
|
const chatInviteMatch = urlp.pathname.match(CHAT_INVITE_CODE_REGEX)
|
||||||
|
if (chatInviteMatch) {
|
||||||
|
setActiveLanding({
|
||||||
|
type: 'groupchat',
|
||||||
|
uri: linkingUrl,
|
||||||
|
code: chatInviteMatch[1],
|
||||||
|
})
|
||||||
|
setReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So,
|
// On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So,
|
||||||
// let's just ensure we never check again after the first time.
|
// let's just ensure we never check again after the first time.
|
||||||
if (hasCheckedForStarterPack) {
|
if (hasCheckedForStarterPack) {
|
||||||
@@ -29,7 +52,8 @@ export function useStarterPackEntry() {
|
|||||||
setReady(true)
|
setReady(true)
|
||||||
}, 500)
|
}, 500)
|
||||||
|
|
||||||
;(async () => {
|
void (async () => {
|
||||||
|
// Check for starter pack
|
||||||
let uri: string | null | undefined
|
let uri: string | null | undefined
|
||||||
|
|
||||||
if (IS_ANDROID) {
|
if (IS_ANDROID) {
|
||||||
@@ -58,7 +82,7 @@ export function useStarterPackEntry() {
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
}, [ready, setActiveStarterPack, hasCheckedForStarterPack])
|
}, [ready, setActiveStarterPack, setActiveLanding, hasCheckedForStarterPack])
|
||||||
|
|
||||||
return ready
|
return ready
|
||||||
}
|
}
|
||||||
+7
-5
@@ -1,25 +1,27 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
|
|
||||||
import {httpStarterPackUriToAtUri} from '#/lib/strings/starter-pack'
|
import {httpStarterPackUriToAtUri} from '#/lib/strings/starter-pack'
|
||||||
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
|
import {useSetActiveStarterPack} from '#/state/shell/landing'
|
||||||
|
|
||||||
export function useStarterPackEntry() {
|
export function useLandingEntry() {
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
const setActiveStarterPack = useSetActiveStarterPack()
|
const setActiveStarterPack = useSetActiveStarterPack()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const href = window.location.href
|
const href = window.location.href
|
||||||
const atUri = httpStarterPackUriToAtUri(href)
|
const url = new URL(href)
|
||||||
|
|
||||||
|
// Check for starter pack
|
||||||
|
const atUri = httpStarterPackUriToAtUri(href)
|
||||||
if (atUri) {
|
if (atUri) {
|
||||||
const url = new URL(href)
|
|
||||||
// Determines if an App Clip is loading this landing page
|
// Determines if an App Clip is loading this landing page
|
||||||
const isClip = url.searchParams.get('clip') === 'true'
|
const isClip = url.searchParams.get('clip') === 'true'
|
||||||
setActiveStarterPack({
|
setActiveStarterPack({
|
||||||
uri: atUri,
|
uri: atUri,
|
||||||
isClip,
|
isClip,
|
||||||
})
|
})
|
||||||
|
setReady(true)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setReady(true)
|
setReady(true)
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
import {forwardRef} from 'react'
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type ForwardRefExoticComponent,
|
||||||
|
type RefAttributes,
|
||||||
|
} from 'react'
|
||||||
import Svg, {Path} from 'react-native-svg'
|
import Svg, {Path} from 'react-native-svg'
|
||||||
|
|
||||||
import {type Props, useCommonSVGProps} from '#/components/icons/common'
|
import {type Props, useCommonSVGProps} from '#/components/icons/common'
|
||||||
|
|
||||||
|
export type IconWithSvgMeta = ForwardRefExoticComponent<
|
||||||
|
Props & RefAttributes<Svg>
|
||||||
|
> & {
|
||||||
|
svgPaths: string[]
|
||||||
|
svgViewBox: string
|
||||||
|
svgStrokeWidth: number
|
||||||
|
}
|
||||||
|
|
||||||
export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef(
|
export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef(
|
||||||
function LogoImpl(props: Props, ref) {
|
function LogoImpl(props: Props, ref) {
|
||||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||||
@@ -41,7 +53,7 @@ export function createSinglePathSVG({
|
|||||||
strokeLinecap?: 'butt' | 'round' | 'square'
|
strokeLinecap?: 'butt' | 'round' | 'square'
|
||||||
strokeLinejoin?: 'miter' | 'round' | 'bevel'
|
strokeLinejoin?: 'miter' | 'round' | 'bevel'
|
||||||
}) {
|
}) {
|
||||||
return forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
||||||
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
||||||
|
|
||||||
const hasStroke = strokeWidth > 0
|
const hasStroke = strokeWidth > 0
|
||||||
@@ -68,7 +80,11 @@ export function createSinglePathSVG({
|
|||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
)
|
)
|
||||||
})
|
}) as IconWithSvgMeta
|
||||||
|
Icon.svgPaths = [path]
|
||||||
|
Icon.svgViewBox = viewBox || '0 0 24 24'
|
||||||
|
Icon.svgStrokeWidth = strokeWidth
|
||||||
|
return Icon
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMultiPathSVG({
|
export function createMultiPathSVG({
|
||||||
@@ -78,7 +94,7 @@ export function createMultiPathSVG({
|
|||||||
paths: string[]
|
paths: string[]
|
||||||
viewBox?: string
|
viewBox?: string
|
||||||
}) {
|
}) {
|
||||||
return forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
||||||
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,5 +118,9 @@ export function createMultiPathSVG({
|
|||||||
))}
|
))}
|
||||||
</Svg>
|
</Svg>
|
||||||
)
|
)
|
||||||
})
|
}) as IconWithSvgMeta
|
||||||
|
Icon.svgPaths = paths
|
||||||
|
Icon.svgViewBox = viewBox || '0 0 24 24'
|
||||||
|
Icon.svgStrokeWidth = 0
|
||||||
|
return Icon
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useMemo, useRef} from 'react'
|
import {useEffect, useMemo, useRef} from 'react'
|
||||||
import {type DimensionValue, Pressable, View} from 'react-native'
|
import {type DimensionValue, Pressable, View} from 'react-native'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
type AnimatedRef,
|
type AnimatedRef,
|
||||||
@@ -69,6 +69,8 @@ export function AutoSizedImage({
|
|||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
onPressIn,
|
onPressIn,
|
||||||
|
onContainerRef,
|
||||||
|
onDimsChange,
|
||||||
}: {
|
}: {
|
||||||
image: AppBskyEmbedImages.ViewImage
|
image: AppBskyEmbedImages.ViewImage
|
||||||
crop?: 'none' | 'square' | 'constrained'
|
crop?: 'none' | 'square' | 'constrained'
|
||||||
@@ -79,6 +81,11 @@ export function AutoSizedImage({
|
|||||||
) => void
|
) => void
|
||||||
onLongPress?: () => void
|
onLongPress?: () => void
|
||||||
onPressIn?: () => void
|
onPressIn?: () => void
|
||||||
|
/** Fires once with the internal container ref so a parent can drive its
|
||||||
|
* own lightbox-return animation without waiting for an `onPress`. */
|
||||||
|
onContainerRef?: (ref: AnimatedRef<any>) => void
|
||||||
|
/** Fires when the underlying image reports its natural dimensions. */
|
||||||
|
onDimsChange?: (dims: Dimensions) => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -86,6 +93,10 @@ export function AutoSizedImage({
|
|||||||
const containerRef = useAnimatedRef()
|
const containerRef = useAnimatedRef()
|
||||||
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
|
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onContainerRef?.(containerRef)
|
||||||
|
}, [containerRef, onContainerRef])
|
||||||
|
|
||||||
let aspectRatio: number | undefined
|
let aspectRatio: number | undefined
|
||||||
const dims = image.aspectRatio
|
const dims = image.aspectRatio
|
||||||
if (dims) {
|
if (dims) {
|
||||||
@@ -122,10 +133,12 @@ export function AutoSizedImage({
|
|||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
onLoad={e => {
|
onLoad={e => {
|
||||||
if (!isContain) {
|
if (!isContain) {
|
||||||
fetchedDimsRef.current = {
|
const dims = {
|
||||||
width: e.source.width,
|
width: e.source.width,
|
||||||
height: e.source.height,
|
height: e.source.height,
|
||||||
}
|
}
|
||||||
|
fetchedDimsRef.current = dims
|
||||||
|
onDimsChange?.(dims)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {mergeRefs} from '#/lib/merge-refs'
|
|||||||
import {useA11y} from '#/state/a11y'
|
import {useA11y} from '#/state/a11y'
|
||||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
|
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
|
||||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +36,7 @@ import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandle
|
|||||||
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
|
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
|
||||||
import {getAspectRatio} from '#/components/images/Gallery/utils'
|
import {getAspectRatio} from '#/components/images/Gallery/utils'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
|
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
@@ -271,6 +272,21 @@ export function Gallery({
|
|||||||
data={images}
|
data={images}
|
||||||
keyExtractor={(item, index) => item.thumb + index}
|
keyExtractor={(item, index) => item.thumb + index}
|
||||||
renderItem={({item, index}) => {
|
renderItem={({item, index}) => {
|
||||||
|
const openLightboxAtIndex = onPress
|
||||||
|
? () => {
|
||||||
|
ax.metric('post:gallery:openLightbox', {
|
||||||
|
fromImage: index + 1, // convert to 1-based index for easier analysis
|
||||||
|
totalImages: images.length,
|
||||||
|
})
|
||||||
|
const refs: AnimatedRef<any>[] = []
|
||||||
|
const dims: (Dimensions | null)[] = []
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
refs.push(containerRefsRef.current.get(i)!)
|
||||||
|
dims.push(thumbDimsRef.current.get(i) ?? null)
|
||||||
|
}
|
||||||
|
onPress(index, refs, dims)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
return (
|
return (
|
||||||
<GalleryImage
|
<GalleryImage
|
||||||
hideBadges={hideBadges}
|
hideBadges={hideBadges}
|
||||||
@@ -295,24 +311,9 @@ export function Gallery({
|
|||||||
onThumbDims={(i, dims) => {
|
onThumbDims={(i, dims) => {
|
||||||
thumbDimsRef.current.set(i, dims)
|
thumbDimsRef.current.set(i, dims)
|
||||||
}}
|
}}
|
||||||
onPress={
|
onPress={openLightboxAtIndex}
|
||||||
onPress
|
|
||||||
? () => {
|
|
||||||
ax.metric('post:gallery:openLightbox', {
|
|
||||||
fromImage: index + 1, // convert to 1-based index for easier analysis
|
|
||||||
totalImages: images.length,
|
|
||||||
})
|
|
||||||
const refs: AnimatedRef<any>[] = []
|
|
||||||
const dims: (Dimensions | null)[] = []
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
|
||||||
refs.push(containerRefsRef.current.get(i)!)
|
|
||||||
dims.push(thumbDimsRef.current.get(i) ?? null)
|
|
||||||
}
|
|
||||||
onPress(index, refs, dims)
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||||
|
onPreviewPress={openLightboxAtIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -385,6 +386,7 @@ function GalleryImage({
|
|||||||
onThumbDims,
|
onThumbDims,
|
||||||
onPress,
|
onPress,
|
||||||
onPressIn,
|
onPressIn,
|
||||||
|
onPreviewPress,
|
||||||
}: {
|
}: {
|
||||||
contentHeight: number
|
contentHeight: number
|
||||||
image: AppBskyEmbedImages.ViewImage
|
image: AppBskyEmbedImages.ViewImage
|
||||||
@@ -398,6 +400,7 @@ function GalleryImage({
|
|||||||
onThumbDims: (index: number, dims: Dimensions) => void
|
onThumbDims: (index: number, dims: Dimensions) => void
|
||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
onPressIn?: () => void
|
onPressIn?: () => void
|
||||||
|
onPreviewPress?: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
@@ -423,124 +426,131 @@ function GalleryImage({
|
|||||||
collapsable={false}
|
collapsable={false}
|
||||||
aria-roledescription={l`slide`}
|
aria-roledescription={l`slide`}
|
||||||
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
|
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
|
||||||
<Pressable
|
<ImageContextMenu
|
||||||
ref={itemRef}
|
fullsizeUri={image.fullsize}
|
||||||
tabIndex={index === 0 ? 0 : -1}
|
thumbUri={image.thumb}
|
||||||
onPress={onPress}
|
aspectRatio={aspectRatio}
|
||||||
onPressIn={onPressIn}
|
borderRadius={tokens.borderRadius.md}
|
||||||
onFocus={() => setFocused(true)}
|
onPreviewPress={onPreviewPress}>
|
||||||
onBlur={() => setFocused(false)}
|
<Pressable
|
||||||
accessibilityRole="button"
|
ref={itemRef}
|
||||||
accessibilityLabel={image.alt || l`Image ${index + 1}`}
|
tabIndex={index === 0 ? 0 : -1}
|
||||||
accessibilityHint={l`Opens full image`}
|
onPress={onPress}
|
||||||
android_ripple={{
|
onPressIn={onPressIn}
|
||||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
onFocus={() => setFocused(true)}
|
||||||
foreground: true,
|
onBlur={() => setFocused(false)}
|
||||||
}}
|
accessibilityRole="button"
|
||||||
style={({pressed}) => [
|
accessibilityLabel={image.alt || l`Image ${index + 1}`}
|
||||||
a.rounded_md,
|
accessibilityHint={l`Opens full image`}
|
||||||
a.overflow_hidden,
|
android_ripple={{
|
||||||
t.atoms.bg_contrast_25,
|
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||||
web([
|
foreground: true,
|
||||||
{
|
|
||||||
cursor: 'inherit',
|
|
||||||
outline: 0,
|
|
||||||
border: 0,
|
|
||||||
},
|
|
||||||
a.transition_transform,
|
|
||||||
{transitionDuration: '200ms'},
|
|
||||||
pressed && {transform: [{scale: 0.99}]},
|
|
||||||
]),
|
|
||||||
]}>
|
|
||||||
<Image
|
|
||||||
source={{uri: image.thumb}}
|
|
||||||
contentFit="cover"
|
|
||||||
accessible={true}
|
|
||||||
accessibilityLabel={image.alt}
|
|
||||||
accessibilityHint=""
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
loading={index === 0 ? 'eager' : 'lazy'}
|
|
||||||
style={[dims]}
|
|
||||||
onLoad={e => {
|
|
||||||
const ar = getAspectRatio(e.source)
|
|
||||||
if (ar && ar !== aspectRatio) {
|
|
||||||
setAspectRatio(ar)
|
|
||||||
}
|
|
||||||
onThumbDims(index, {
|
|
||||||
width: e.source.width,
|
|
||||||
height: e.source.height,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
style={({pressed}) => [
|
||||||
|
a.rounded_md,
|
||||||
{(hasAlt || isCropped) && !hideBadges ? (
|
a.overflow_hidden,
|
||||||
<View
|
t.atoms.bg_contrast_25,
|
||||||
accessible={false}
|
web([
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.flex_row,
|
|
||||||
{
|
{
|
||||||
bottom: a.p_xs.padding,
|
cursor: 'inherit',
|
||||||
right: a.p_xs.padding,
|
outline: 0,
|
||||||
gap: 3,
|
border: 0,
|
||||||
},
|
},
|
||||||
largeAltBadge && {
|
a.transition_transform,
|
||||||
gap: 4,
|
{transitionDuration: '200ms'},
|
||||||
},
|
pressed && {transform: [{scale: 0.99}]},
|
||||||
]}>
|
]),
|
||||||
{isCropped && (
|
]}>
|
||||||
<View
|
<Image
|
||||||
style={[
|
source={{uri: image.thumb}}
|
||||||
a.rounded_sm,
|
contentFit="cover"
|
||||||
a.p_xs,
|
accessible={true}
|
||||||
t.atoms.bg_contrast_25,
|
accessibilityLabel={image.alt}
|
||||||
{
|
accessibilityHint=""
|
||||||
opacity: 0.8,
|
accessibilityIgnoresInvertColors
|
||||||
},
|
loading={index === 0 ? 'eager' : 'lazy'}
|
||||||
largeAltBadge && {
|
style={[dims]}
|
||||||
padding: 6,
|
onLoad={e => {
|
||||||
},
|
const ar = getAspectRatio(e.source)
|
||||||
]}>
|
if (ar && ar !== aspectRatio) {
|
||||||
<Fullscreen
|
setAspectRatio(ar)
|
||||||
fill={t.atoms.text_contrast_high.color}
|
}
|
||||||
width={largeAltBadge ? 18 : 12}
|
onThumbDims(index, {
|
||||||
/>
|
width: e.source.width,
|
||||||
</View>
|
height: e.source.height,
|
||||||
)}
|
})
|
||||||
{hasAlt && (
|
}}
|
||||||
<View
|
/>
|
||||||
style={[
|
|
||||||
a.justify_center,
|
|
||||||
a.rounded_sm,
|
|
||||||
a.p_xs,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
{
|
|
||||||
opacity: 0.8,
|
|
||||||
},
|
|
||||||
largeAltBadge && {
|
|
||||||
padding: 6,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.font_bold,
|
|
||||||
largeAltBadge ? a.text_xs : {fontSize: 8},
|
|
||||||
]}>
|
|
||||||
<Trans>ALT</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<MediaInsetBorder
|
{(hasAlt || isCropped) && !hideBadges ? (
|
||||||
style={
|
<View
|
||||||
focused && {
|
accessible={false}
|
||||||
borderWidth: 2,
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.flex_row,
|
||||||
|
{
|
||||||
|
bottom: a.p_xs.padding,
|
||||||
|
right: a.p_xs.padding,
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
largeAltBadge && {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{isCropped && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_sm,
|
||||||
|
a.p_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
largeAltBadge && {
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Fullscreen
|
||||||
|
fill={t.atoms.text_contrast_high.color}
|
||||||
|
width={largeAltBadge ? 18 : 12}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{hasAlt && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.p_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
largeAltBadge && {
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
largeAltBadge ? a.text_xs : {fontSize: 8},
|
||||||
|
]}>
|
||||||
|
<Trans>ALT</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MediaInsetBorder
|
||||||
|
style={
|
||||||
|
focused && {
|
||||||
|
borderWidth: 2,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</Pressable>
|
||||||
</Pressable>
|
</ImageContextMenu>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {type Dimensions} from '#/lib/media/types'
|
import {type Dimensions} from '#/lib/media/types'
|
||||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
|
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
@@ -52,46 +53,63 @@ export function GalleryItem({
|
|||||||
const hasAlt = !!image.alt
|
const hasAlt = !!image.alt
|
||||||
const hideBadges =
|
const hideBadges =
|
||||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
|
|
||||||
|
const aspect =
|
||||||
|
image.aspectRatio && image.aspectRatio.height > 0
|
||||||
|
? image.aspectRatio.width / image.aspectRatio.height
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// The tap handler and the peek-commit handler do the same thing: open the
|
||||||
|
// lightbox with this cell's ref + dims so the lightbox's return animation
|
||||||
|
// can target the original thumbnail.
|
||||||
|
const openLightboxAtIndex = onPress
|
||||||
|
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
|
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
|
||||||
<Pressable
|
<ImageContextMenu
|
||||||
onPress={
|
fullsizeUri={image.fullsize}
|
||||||
onPress
|
thumbUri={image.thumb}
|
||||||
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
|
aspectRatio={aspect}
|
||||||
: undefined
|
borderRadius={tokens.borderRadius.md}
|
||||||
}
|
onPreviewPress={openLightboxAtIndex}
|
||||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
style={a.flex_1}>
|
||||||
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
|
<Pressable
|
||||||
android_ripple={{
|
onPress={openLightboxAtIndex}
|
||||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||||
foreground: true,
|
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
|
||||||
}}
|
android_ripple={{
|
||||||
style={[
|
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||||
a.flex_1,
|
foreground: true,
|
||||||
a.overflow_hidden,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
imageStyle,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={image.alt || _(msg`Image`)}
|
|
||||||
accessibilityHint="">
|
|
||||||
<Image
|
|
||||||
source={{uri: image.thumb}}
|
|
||||||
style={[a.flex_1]}
|
|
||||||
accessible={true}
|
|
||||||
accessibilityLabel={image.alt}
|
|
||||||
accessibilityHint=""
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
onLoad={e => {
|
|
||||||
thumbDimsRef.current[index] = {
|
|
||||||
width: e.source.width,
|
|
||||||
height: e.source.height,
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
loading="lazy"
|
style={[
|
||||||
/>
|
a.flex_1,
|
||||||
<MediaInsetBorder style={insetBorderStyle} />
|
a.overflow_hidden,
|
||||||
</Pressable>
|
t.atoms.bg_contrast_25,
|
||||||
|
imageStyle,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={image.alt || _(msg`Image`)}
|
||||||
|
accessibilityHint="">
|
||||||
|
<Image
|
||||||
|
source={{uri: image.thumb}}
|
||||||
|
style={[a.flex_1]}
|
||||||
|
accessible={true}
|
||||||
|
accessibilityLabel={image.alt}
|
||||||
|
accessibilityHint=""
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
onLoad={e => {
|
||||||
|
thumbDimsRef.current[index] = {
|
||||||
|
width: e.source.width,
|
||||||
|
height: e.source.height,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<MediaInsetBorder style={insetBorderStyle} />
|
||||||
|
</Pressable>
|
||||||
|
</ImageContextMenu>
|
||||||
{hasAlt && !hideBadges ? (
|
{hasAlt && !hideBadges ? (
|
||||||
<View
|
<View
|
||||||
accessible={false}
|
accessible={false}
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {
|
||||||
|
ChatBskyGroupRequestJoin,
|
||||||
|
ChatBskyGroupWithdrawJoinRequest,
|
||||||
|
moderateProfile,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
|
||||||
|
import {useRequestJoinGroupChat} from '#/state/queries/messages/request-join-group-chat'
|
||||||
|
import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
|
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
type ButtonColor,
|
||||||
|
ButtonIcon,
|
||||||
|
ButtonText,
|
||||||
|
} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||||||
|
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
|
||||||
|
import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
|
||||||
|
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||||
|
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
|
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||||
|
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||||
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {ProfileBadges} from '../ProfileBadges'
|
||||||
|
|
||||||
|
export function GroupChatJoinDialog() {
|
||||||
|
const {groupChatJoinDialogControl, groupChatJoinState} = useIntentDialogs()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer
|
||||||
|
control={groupChatJoinDialogControl}
|
||||||
|
nativeOptions={{preventExpansion: true}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<GroupChatJoinDialogInner code={groupChatJoinState?.code} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupChatJoinDialogInner({code}: {code?: string}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={l`Join group chat`}
|
||||||
|
style={[web({maxWidth: 400, borderRadius: 36})]}>
|
||||||
|
<View style={[a.gap_2xl, a.align_center]}>
|
||||||
|
<GroupChatJoinDialogContent code={code} />
|
||||||
|
</View>
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const {groupChatJoinDialogControl: control} = useIntentDialogs()
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
|
||||||
|
codes: code ? [code] : undefined,
|
||||||
|
hasSession,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {mutate: joinGroupChat, isPending: isJoinPending} =
|
||||||
|
useRequestJoinGroupChat({
|
||||||
|
onSuccess: data => {
|
||||||
|
switch (data.status) {
|
||||||
|
case 'pending':
|
||||||
|
control.close(() => {
|
||||||
|
Toast.show(
|
||||||
|
l`Access requested! The group owner will review your request.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'joined': {
|
||||||
|
if (data.convo && data.convo.id) {
|
||||||
|
control.close(() => {
|
||||||
|
Toast.show(l`Successfully joined the group chat!`)
|
||||||
|
navigation.navigate('MessagesConversation', {
|
||||||
|
conversation: data.convo!.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.warn('Request to join group chat returned no convo ID', {
|
||||||
|
status: data.status,
|
||||||
|
convoId: data.convo?.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: error => {
|
||||||
|
let errorMessage = l`Failed to join the group chat. Please try again.`
|
||||||
|
if (isNetworkError(error)) {
|
||||||
|
errorMessage = l`There was a problem with your internet connection, please try again`
|
||||||
|
} else if (error instanceof ChatBskyGroupRequestJoin.ConvoLockedError) {
|
||||||
|
errorMessage = l`This conversation is locked.`
|
||||||
|
} else if (
|
||||||
|
error instanceof ChatBskyGroupRequestJoin.FollowRequiredError
|
||||||
|
) {
|
||||||
|
errorMessage = l`Only followers can join this group chat.`
|
||||||
|
} else if (error instanceof ChatBskyGroupRequestJoin.InvalidCodeError) {
|
||||||
|
errorMessage = l`Invalid group chat code.`
|
||||||
|
} else if (
|
||||||
|
error instanceof ChatBskyGroupRequestJoin.LinkDisabledError
|
||||||
|
) {
|
||||||
|
errorMessage = l`This invite link has been disabled.`
|
||||||
|
} else if (
|
||||||
|
error instanceof ChatBskyGroupRequestJoin.MemberLimitReachedError
|
||||||
|
) {
|
||||||
|
errorMessage = l`The member limit has been reached.`
|
||||||
|
} else if (error instanceof ChatBskyGroupRequestJoin.UserKickedError) {
|
||||||
|
errorMessage = l`You have been removed from this group.`
|
||||||
|
}
|
||||||
|
Toast.show(errorMessage)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {mutate: withdrawRequest, isPending: isWithdrawPending} =
|
||||||
|
useWithdrawJoinGroupChatRequest({
|
||||||
|
onSuccess: () => {
|
||||||
|
control.close(() => {
|
||||||
|
Toast.show(l`Join request rescinded.`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: error => {
|
||||||
|
let errorMessage = l`Failed to rescind your request. Please try again.`
|
||||||
|
if (isNetworkError(error)) {
|
||||||
|
errorMessage = l`There was a problem with your internet connection, please try again`
|
||||||
|
} else if (
|
||||||
|
error instanceof
|
||||||
|
ChatBskyGroupWithdrawJoinRequest.InvalidJoinRequestError
|
||||||
|
) {
|
||||||
|
errorMessage = l`Invalid rescind request.`
|
||||||
|
}
|
||||||
|
Toast.show(errorMessage)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
state: interacted,
|
||||||
|
onIn: onInteract,
|
||||||
|
onOut: onInteractOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
|
||||||
|
const handleJoin = () => {
|
||||||
|
if (!code) return
|
||||||
|
joinGroupChat({code})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWithdraw = () => {
|
||||||
|
if (!convoId) return
|
||||||
|
withdrawRequest({convoId})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if the prefetch exceeds the timeout
|
||||||
|
if (isLoading || !data || !moderationOpts) {
|
||||||
|
return (
|
||||||
|
<View style={[a.p_2xl]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChainLinkBrokenIcon fill={t.palette.primary_500} size="3xl" />
|
||||||
|
<Text
|
||||||
|
style={[a.text_center, a.text_lg, a.font_semi_bold, t.atoms.text]}>
|
||||||
|
<Trans>This invite link is invalid</Trans>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
label={l`Close this dialog`}
|
||||||
|
accessibilityHint={l`Close this dialog`}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinLinkPreview = data.joinLinkPreviews[0]
|
||||||
|
|
||||||
|
if (!joinLinkPreview) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={[a.py_lg, a.align_center]}>
|
||||||
|
<View style={[a.gap_sm, a.align_center, a.mt_lg]}>
|
||||||
|
<WarningIcon size="3xl" fill={t.atoms.text_contrast_high.color} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.mb_2xs,
|
||||||
|
a.text_center,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
<Trans>Chat invite link no longer available</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
testID="joinButton"
|
||||||
|
onPress={() => control.close()}
|
||||||
|
label={l`Close this dialog`}
|
||||||
|
accessibilityHint={l`Close this dialog`}
|
||||||
|
size="large"
|
||||||
|
color="secondary"
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<ButtonText>{l`Close`}</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const convoId = joinLinkPreview.convo?.id
|
||||||
|
const isFollowing = joinLinkPreview.owner.viewer?.following ?? false
|
||||||
|
const hasRequested = !convoId && joinLinkPreview.viewer?.requestedAt != null
|
||||||
|
|
||||||
|
let canJoin = true
|
||||||
|
let ButtonIconImage = isJoinPending || isWithdrawPending ? Loader : JoinIcon
|
||||||
|
let buttonText = joinLinkPreview.requireApproval
|
||||||
|
? l`Request to join`
|
||||||
|
: l`Join`
|
||||||
|
let buttonColor: ButtonColor = 'primary'
|
||||||
|
if (joinLinkPreview.enabledStatus !== 'enabled') {
|
||||||
|
canJoin = false
|
||||||
|
ButtonIconImage = WarningIcon
|
||||||
|
buttonText = l`Chat invite link no longer available`
|
||||||
|
buttonColor = 'secondary'
|
||||||
|
} else if (joinLinkPreview.memberCount >= joinLinkPreview.memberLimit) {
|
||||||
|
canJoin = false
|
||||||
|
ButtonIconImage = HandIcon
|
||||||
|
buttonText = l`This chat is full`
|
||||||
|
buttonColor = 'secondary'
|
||||||
|
} else if (joinLinkPreview.joinRule === 'followedByOwner' && !isFollowing) {
|
||||||
|
canJoin = false
|
||||||
|
ButtonIconImage = HandIcon
|
||||||
|
buttonText = l`Only people the chat owner follows can join`
|
||||||
|
buttonColor = 'secondary'
|
||||||
|
} else if (hasRequested) {
|
||||||
|
ButtonIconImage = XIcon
|
||||||
|
buttonText = l`Rescind request`
|
||||||
|
buttonColor = 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={[a.py_lg, a.align_center]}>
|
||||||
|
<AvatarBubbles
|
||||||
|
profiles={[
|
||||||
|
joinLinkPreview.owner,
|
||||||
|
...Array(joinLinkPreview.memberCount - 1).fill(undefined),
|
||||||
|
]}
|
||||||
|
self
|
||||||
|
size={135}
|
||||||
|
/>
|
||||||
|
<View style={[a.gap_sm, a.align_center, a.mt_lg]}>
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.mb_2xs,
|
||||||
|
a.text_center,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
<Trans>Group chat</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.text_center, a.text_3xl, a.font_bold, t.atoms.text]}>
|
||||||
|
{joinLinkPreview.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_center, a.text_xs, a.leading_snug, t.atoms.text]}>
|
||||||
|
<Trans comment="The number of active group chat members out of the total number allowed.">
|
||||||
|
{joinLinkPreview.memberCount}/{joinLinkPreview.memberLimit}{' '}
|
||||||
|
members
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, a.ml_md]}>
|
||||||
|
<PersonGroupIcon
|
||||||
|
size="xs"
|
||||||
|
style={[a.mr_xs, t.atoms.text, {marginTop: -2}]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[a.text_center, a.text_xs, a.leading_snug, t.atoms.text]}>
|
||||||
|
{joinLinkPreview.joinRule === 'followedByOwner'
|
||||||
|
? l`Followers can join`
|
||||||
|
: l`Anyone can join`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<View
|
||||||
|
style={[a.flex_row, a.gap_xs, a.align_center, a.justify_center]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.mb_2xs,
|
||||||
|
a.text_center,
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_snug,
|
||||||
|
a.font_semi_bold,
|
||||||
|
t.atoms.text,
|
||||||
|
]}>
|
||||||
|
By{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
label={`@${joinLinkPreview.owner.handle}`}
|
||||||
|
to={makeProfileLink(joinLinkPreview.owner)}
|
||||||
|
style={[
|
||||||
|
a.mb_2xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_semi_bold,
|
||||||
|
t.atoms.text,
|
||||||
|
interacted && {
|
||||||
|
...web({
|
||||||
|
outline: 0,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
textDecorationColor: t.palette.contrast_1000,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
{...web({
|
||||||
|
onMouseEnter: () => {
|
||||||
|
onInteract()
|
||||||
|
},
|
||||||
|
onMouseLeave: () => {
|
||||||
|
onInteractOut()
|
||||||
|
},
|
||||||
|
})}>
|
||||||
|
{createSanitizedDisplayName(
|
||||||
|
joinLinkPreview.owner,
|
||||||
|
true,
|
||||||
|
moderateProfile(joinLinkPreview.owner, moderationOpts).ui(
|
||||||
|
'displayName',
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</InlineLinkText>
|
||||||
|
</Text>
|
||||||
|
<ProfileBadges
|
||||||
|
profile={data.joinLinkPreviews[0].owner}
|
||||||
|
size="sm"
|
||||||
|
style={{marginTop: -3}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_center,
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
{sanitizeHandle(joinLinkPreview.owner.handle, '@')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{convoId ? (
|
||||||
|
<Button
|
||||||
|
testID="openButton"
|
||||||
|
onPress={() => {
|
||||||
|
control.close(() => {
|
||||||
|
navigation.navigate('MessagesConversation', {
|
||||||
|
conversation: convoId,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
label={l`Open group chat`}
|
||||||
|
accessibilityHint={l`Open this group chat`}
|
||||||
|
size="large"
|
||||||
|
color="primary"
|
||||||
|
disabled={!code}
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<ButtonText>Open chat</ButtonText>
|
||||||
|
<ButtonIcon icon={ArrowRightIcon} />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
testID="joinButton"
|
||||||
|
onPress={hasRequested ? handleWithdraw : handleJoin}
|
||||||
|
label={
|
||||||
|
joinLinkPreview.requireApproval
|
||||||
|
? l`Request access to group chat`
|
||||||
|
: l`Join group chat`
|
||||||
|
}
|
||||||
|
accessibilityHint={
|
||||||
|
joinLinkPreview.requireApproval
|
||||||
|
? l`Request access to join this group chat`
|
||||||
|
: l`Join this group chat`
|
||||||
|
}
|
||||||
|
size="large"
|
||||||
|
color={buttonColor}
|
||||||
|
disabled={isJoinPending || isWithdrawPending || !code || !canJoin}
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<ButtonIcon icon={ButtonIconImage} />
|
||||||
|
<ButtonText>{buttonText}</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
import {createContext, useContext, useMemo, useState} from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
import {usePrefetchJoinLinkPreviews} from '#/state/queries/join-links'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {
|
||||||
|
useActiveGroupChatJoinRequest,
|
||||||
|
useSetActiveLanding,
|
||||||
|
} from '#/state/shell/landing'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {type DialogControlProps} from '#/components/Dialog'
|
import {type DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {GroupChatJoinDialog} from '#/components/intents/GroupChatJoinDialog'
|
||||||
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
|
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
verifyEmailDialogControl: DialogControlProps
|
verifyEmailDialogControl: DialogControlProps
|
||||||
verifyEmailState: {code: string} | undefined
|
verifyEmailState: {code: string} | undefined
|
||||||
setVerifyEmailState: (state: {code: string} | undefined) => void
|
setVerifyEmailState: (state: {code: string} | undefined) => void
|
||||||
|
groupChatJoinDialogControl: DialogControlProps
|
||||||
|
groupChatJoinState: {code: string} | undefined
|
||||||
|
setGroupChatJoinState: (state: {code: string} | undefined) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Context = createContext({} as Context)
|
const Context = createContext({} as Context)
|
||||||
@@ -19,20 +36,70 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
const [verifyEmailState, setVerifyEmailState] = useState<
|
const [verifyEmailState, setVerifyEmailState] = useState<
|
||||||
{code: string} | undefined
|
{code: string} | undefined
|
||||||
>()
|
>()
|
||||||
|
const groupChatJoinDialogControl = Dialog.useDialogControl()
|
||||||
|
const [groupChatJoinState, setGroupChatJoinState] = useState<
|
||||||
|
{code: string} | undefined
|
||||||
|
>()
|
||||||
|
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
const groupChatLanding = useActiveGroupChatJoinRequest()
|
||||||
|
const setActiveLanding = useSetActiveLanding()
|
||||||
|
const prefetchJoinLinkPreviews = usePrefetchJoinLinkPreviews()
|
||||||
|
const landingHandledRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasSession && groupChatLanding && !landingHandledRef.current) {
|
||||||
|
landingHandledRef.current = true
|
||||||
|
const code = groupChatLanding.code
|
||||||
|
setActiveLanding(undefined)
|
||||||
|
const prefetch = prefetchJoinLinkPreviews({
|
||||||
|
codes: [code],
|
||||||
|
hasSession: true,
|
||||||
|
})
|
||||||
|
void Promise.race([
|
||||||
|
prefetch,
|
||||||
|
new Promise(res => setTimeout(res, 200)),
|
||||||
|
]).finally(() => {
|
||||||
|
setGroupChatJoinState({code})
|
||||||
|
groupChatJoinDialogControl.open()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!groupChatLanding) {
|
||||||
|
landingHandledRef.current = false
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
hasSession,
|
||||||
|
groupChatLanding,
|
||||||
|
setActiveLanding,
|
||||||
|
setGroupChatJoinState,
|
||||||
|
prefetchJoinLinkPreviews,
|
||||||
|
groupChatJoinDialogControl,
|
||||||
|
])
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
verifyEmailDialogControl,
|
verifyEmailDialogControl,
|
||||||
verifyEmailState,
|
verifyEmailState,
|
||||||
setVerifyEmailState,
|
setVerifyEmailState,
|
||||||
|
groupChatJoinDialogControl,
|
||||||
|
groupChatJoinState,
|
||||||
|
setGroupChatJoinState,
|
||||||
}),
|
}),
|
||||||
[verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
|
[
|
||||||
|
verifyEmailDialogControl,
|
||||||
|
verifyEmailState,
|
||||||
|
setVerifyEmailState,
|
||||||
|
groupChatJoinDialogControl,
|
||||||
|
groupChatJoinState,
|
||||||
|
setGroupChatJoinState,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Context.Provider value={value}>
|
<Context.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
<VerifyEmailIntentDialog />
|
<VerifyEmailIntentDialog />
|
||||||
|
<GroupChatJoinDialog />
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,9 +75,11 @@ export function ReportDialog(
|
|||||||
() => (props.subject ? parseReportSubject(props.subject) : undefined),
|
() => (props.subject ? parseReportSubject(props.subject) : undefined),
|
||||||
[props.subject],
|
[props.subject],
|
||||||
)
|
)
|
||||||
|
const propsOnClose = props.onClose
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
ax.metric('reportDialog:close', {})
|
ax.metric('reportDialog:close', {})
|
||||||
}, [ax])
|
propsOnClose?.()
|
||||||
|
}, [ax, propsOnClose])
|
||||||
return (
|
return (
|
||||||
<Dialog.Outer control={props.control} onClose={onClose}>
|
<Dialog.Outer control={props.control} onClose={onClose}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
|
|||||||
@@ -88,4 +88,8 @@ export type ReportDialogProps = {
|
|||||||
* Called if the report was successfully submitted.
|
* Called if the report was successfully submitted.
|
||||||
*/
|
*/
|
||||||
onAfterSubmit?: () => void
|
onAfterSubmit?: () => void
|
||||||
|
/**
|
||||||
|
* Called after the dialog finishes closing.
|
||||||
|
*/
|
||||||
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-21
@@ -51,16 +51,10 @@ interface PostOpts {
|
|||||||
langs?: string[]
|
langs?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeatureFlags = {
|
|
||||||
highResolutionImages?: boolean
|
|
||||||
increasedBlobSizeLimit?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function post(
|
export async function post(
|
||||||
agent: BskyAgent,
|
agent: BskyAgent,
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
opts: PostOpts,
|
opts: PostOpts,
|
||||||
featureFlags?: FeatureFlags,
|
|
||||||
) {
|
) {
|
||||||
const thread = opts.thread
|
const thread = opts.thread
|
||||||
opts.onStateChange?.(t`Processing...`)
|
opts.onStateChange?.(t`Processing...`)
|
||||||
@@ -97,7 +91,6 @@ export async function post(
|
|||||||
queryClient,
|
queryClient,
|
||||||
draft,
|
draft,
|
||||||
opts.onStateChange,
|
opts.onStateChange,
|
||||||
featureFlags,
|
|
||||||
)
|
)
|
||||||
let labels: $Typed<ComAtprotoLabelDefs.SelfLabels> | undefined
|
let labels: $Typed<ComAtprotoLabelDefs.SelfLabels> | undefined
|
||||||
if (draft.labels.length) {
|
if (draft.labels.length) {
|
||||||
@@ -257,7 +250,6 @@ async function resolveEmbed(
|
|||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
draft: PostDraft,
|
draft: PostDraft,
|
||||||
onStateChange: ((state: string) => void) | undefined,
|
onStateChange: ((state: string) => void) | undefined,
|
||||||
featureFlags?: FeatureFlags,
|
|
||||||
): Promise<
|
): Promise<
|
||||||
| $Typed<AppBskyEmbedImages.Main>
|
| $Typed<AppBskyEmbedImages.Main>
|
||||||
| $Typed<AppBskyEmbedVideo.Main>
|
| $Typed<AppBskyEmbedVideo.Main>
|
||||||
@@ -268,13 +260,7 @@ async function resolveEmbed(
|
|||||||
> {
|
> {
|
||||||
if (draft.embed.quote) {
|
if (draft.embed.quote) {
|
||||||
const [resolvedMedia, resolvedQuote] = await Promise.all([
|
const [resolvedMedia, resolvedQuote] = await Promise.all([
|
||||||
resolveMedia(
|
resolveMedia(agent, queryClient, draft.embed, onStateChange),
|
||||||
agent,
|
|
||||||
queryClient,
|
|
||||||
draft.embed,
|
|
||||||
onStateChange,
|
|
||||||
featureFlags,
|
|
||||||
),
|
|
||||||
resolveRecord(agent, queryClient, draft.embed.quote.uri),
|
resolveRecord(agent, queryClient, draft.embed.quote.uri),
|
||||||
])
|
])
|
||||||
if (resolvedMedia) {
|
if (resolvedMedia) {
|
||||||
@@ -297,7 +283,6 @@ async function resolveEmbed(
|
|||||||
queryClient,
|
queryClient,
|
||||||
draft.embed,
|
draft.embed,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
featureFlags,
|
|
||||||
)
|
)
|
||||||
if (resolvedMedia) {
|
if (resolvedMedia) {
|
||||||
return resolvedMedia
|
return resolvedMedia
|
||||||
@@ -323,7 +308,6 @@ async function resolveMedia(
|
|||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
embedDraft: EmbedDraft,
|
embedDraft: EmbedDraft,
|
||||||
onStateChange: ((state: string) => void) | undefined,
|
onStateChange: ((state: string) => void) | undefined,
|
||||||
featureFlags?: FeatureFlags,
|
|
||||||
): Promise<
|
): Promise<
|
||||||
| $Typed<AppBskyEmbedExternal.Main>
|
| $Typed<AppBskyEmbedExternal.Main>
|
||||||
| $Typed<AppBskyEmbedImages.Main>
|
| $Typed<AppBskyEmbedImages.Main>
|
||||||
@@ -339,10 +323,7 @@ async function resolveMedia(
|
|||||||
const images: AppBskyEmbedImages.Image[] = await Promise.all(
|
const images: AppBskyEmbedImages.Image[] = await Promise.all(
|
||||||
imagesDraft.map(async (image, i) => {
|
imagesDraft.map(async (image, i) => {
|
||||||
logger.debug(`Compressing image #${i}`)
|
logger.debug(`Compressing image #${i}`)
|
||||||
const {path, width, height, mime} = await compressImage(image, {
|
const {path, width, height, mime} = await compressImage(image)
|
||||||
highResolution: featureFlags?.highResolutionImages,
|
|
||||||
increasedBlobSizeLimit: featureFlags?.increasedBlobSizeLimit,
|
|
||||||
})
|
|
||||||
logger.debug(`Uploading image #${i}`)
|
logger.debug(`Uploading image #${i}`)
|
||||||
const res = await uploadBlob(agent, path, mime)
|
const res = await uploadBlob(agent, path, mime)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -246,6 +246,10 @@ async function resolveExternal(
|
|||||||
title: result.title ?? '',
|
title: result.title ?? '',
|
||||||
description: result.description ?? '',
|
description: result.description ?? '',
|
||||||
thumb: result.image ? await imageToThumb(result.image) : undefined,
|
thumb: result.image ? await imageToThumb(result.image) : undefined,
|
||||||
|
/*
|
||||||
|
* New fields from Standard Site integration. Other fields are derived from
|
||||||
|
* opengraph/oembed as before.
|
||||||
|
*/
|
||||||
associatedRefs: result.associatedRefs,
|
associatedRefs: result.associatedRefs,
|
||||||
view: result.view,
|
view: result.view,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
|
|||||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||||
const HELP_DESK_LANG = 'en-us'
|
const HELP_DESK_LANG = 'en-us'
|
||||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
||||||
|
export const CHAT_SERVICE = 'https://api.bsky.chat'
|
||||||
export const EMBED_SERVICE = 'https://embed.bsky.app'
|
export const EMBED_SERVICE = 'https://embed.bsky.app'
|
||||||
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||||
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
|
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
|
|||||||
|
|
||||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
||||||
|
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
|
||||||
|
import {usePrefetchJoinLinkPreviews} from '#/state/queries/join-links'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
|
import {useSetActiveLanding} from '#/state/shell/landing'
|
||||||
|
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
@@ -25,6 +29,7 @@ export function useIntentHandler() {
|
|||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const composeIntent = useComposeIntent()
|
const composeIntent = useComposeIntent()
|
||||||
const verifyEmailIntent = useVerifyEmailIntent()
|
const verifyEmailIntent = useVerifyEmailIntent()
|
||||||
|
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
|
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
|
||||||
|
|
||||||
@@ -44,6 +49,11 @@ export function useIntentHandler() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const urlp = parseLinkingUrl(url)
|
const urlp = parseLinkingUrl(url)
|
||||||
|
const chatInviteMatch = urlp.pathname.match(CHAT_INVITE_CODE_REGEX)
|
||||||
|
if (chatInviteMatch) {
|
||||||
|
groupChatJoinIntent(chatInviteMatch[1], url)
|
||||||
|
return
|
||||||
|
}
|
||||||
const [, intent, intentType] = urlp.pathname.split('/')
|
const [, intent, intentType] = urlp.pathname.split('/')
|
||||||
|
|
||||||
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
|
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
|
||||||
@@ -99,6 +109,7 @@ export function useIntentHandler() {
|
|||||||
ax,
|
ax,
|
||||||
composeIntent,
|
composeIntent,
|
||||||
verifyEmailIntent,
|
verifyEmailIntent,
|
||||||
|
groupChatJoinIntent,
|
||||||
currentAccount,
|
currentAccount,
|
||||||
tryApplyUpdate,
|
tryApplyUpdate,
|
||||||
])
|
])
|
||||||
@@ -162,6 +173,46 @@ export function useComposeIntent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useGroupChatJoinIntent() {
|
||||||
|
const closeAllActiveElements = useCloseAllActiveElements()
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
const {groupChatJoinDialogControl: control, setGroupChatJoinState: setState} =
|
||||||
|
useIntentDialogs()
|
||||||
|
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||||
|
const setActiveLanding = useSetActiveLanding()
|
||||||
|
const prefetchJoinLinkPreviews = usePrefetchJoinLinkPreviews()
|
||||||
|
return useCallback(
|
||||||
|
(code: string, uri?: string) => {
|
||||||
|
closeAllActiveElements()
|
||||||
|
if (hasSession) {
|
||||||
|
setState({code})
|
||||||
|
const prefetch = prefetchJoinLinkPreviews({
|
||||||
|
codes: [code],
|
||||||
|
hasSession: true,
|
||||||
|
})
|
||||||
|
void Promise.race([
|
||||||
|
prefetch,
|
||||||
|
new Promise(res => setTimeout(res, 200)),
|
||||||
|
]).finally(() => {
|
||||||
|
control.open()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setActiveLanding({type: 'groupchat', uri: uri ?? '', code})
|
||||||
|
requestSwitchToAccount({requestedAccount: 'groupchat'})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
closeAllActiveElements,
|
||||||
|
hasSession,
|
||||||
|
control,
|
||||||
|
setState,
|
||||||
|
prefetchJoinLinkPreviews,
|
||||||
|
requestSwitchToAccount,
|
||||||
|
setActiveLanding,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function useVerifyEmailIntent() {
|
function useVerifyEmailIntent() {
|
||||||
const closeAllActiveElements = useCloseAllActiveElements()
|
const closeAllActiveElements = useCloseAllActiveElements()
|
||||||
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
|
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ export function dateDiff(
|
|||||||
if (diffSeconds < NOW) {
|
if (diffSeconds < NOW) {
|
||||||
diff = {
|
diff = {
|
||||||
value: 0,
|
value: 0,
|
||||||
unit: 'now' as DateDiff['unit'],
|
unit: 'now',
|
||||||
}
|
}
|
||||||
} else if (diffSeconds < MINUTE) {
|
} else if (diffSeconds < MINUTE) {
|
||||||
diff = {
|
diff = {
|
||||||
value: diffSeconds,
|
value: diffSeconds,
|
||||||
unit: 'second' as DateDiff['unit'],
|
unit: 'second',
|
||||||
}
|
}
|
||||||
} else if (diffSeconds < HOUR) {
|
} else if (diffSeconds < HOUR) {
|
||||||
const value =
|
const value =
|
||||||
@@ -72,7 +72,7 @@ export function dateDiff(
|
|||||||
: Math.floor(diffSeconds / MINUTE)
|
: Math.floor(diffSeconds / MINUTE)
|
||||||
diff = {
|
diff = {
|
||||||
value,
|
value,
|
||||||
unit: 'minute' as DateDiff['unit'],
|
unit: 'minute',
|
||||||
}
|
}
|
||||||
} else if (diffSeconds < DAY) {
|
} else if (diffSeconds < DAY) {
|
||||||
const value =
|
const value =
|
||||||
@@ -81,7 +81,7 @@ export function dateDiff(
|
|||||||
: Math.floor(diffSeconds / HOUR)
|
: Math.floor(diffSeconds / HOUR)
|
||||||
diff = {
|
diff = {
|
||||||
value,
|
value,
|
||||||
unit: 'hour' as DateDiff['unit'],
|
unit: 'hour',
|
||||||
}
|
}
|
||||||
} else if (diffSeconds < MONTH_30) {
|
} else if (diffSeconds < MONTH_30) {
|
||||||
const value =
|
const value =
|
||||||
@@ -90,7 +90,7 @@ export function dateDiff(
|
|||||||
: Math.floor(diffSeconds / DAY)
|
: Math.floor(diffSeconds / DAY)
|
||||||
diff = {
|
diff = {
|
||||||
value,
|
value,
|
||||||
unit: 'day' as DateDiff['unit'],
|
unit: 'day',
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const value =
|
const value =
|
||||||
@@ -99,7 +99,7 @@ export function dateDiff(
|
|||||||
: Math.floor(diffSeconds / MONTH_30)
|
: Math.floor(diffSeconds / MONTH_30)
|
||||||
diff = {
|
diff = {
|
||||||
value,
|
value,
|
||||||
unit: 'month' as DateDiff['unit'],
|
unit: 'month',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export function makeProfileLink(
|
|||||||
export function makeCustomFeedLink(
|
export function makeCustomFeedLink(
|
||||||
did: string,
|
did: string,
|
||||||
rkey: string,
|
rkey: string,
|
||||||
segment?: string | undefined,
|
segment?: string,
|
||||||
feedCacheKey?: 'discover' | 'explore' | undefined,
|
feedCacheKey?: 'discover' | 'explore',
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
[`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') +
|
[`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') +
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export type CommonNavigatorParams = {
|
|||||||
Topic: {topic: string}
|
Topic: {topic: string}
|
||||||
MessagesConversation: {conversation: string; embed?: string; accept?: true}
|
MessagesConversation: {conversation: string; embed?: string; accept?: true}
|
||||||
MessagesConversationSettings: {conversation: string}
|
MessagesConversationSettings: {conversation: string}
|
||||||
|
MessagesJoinRequests: {conversation: string}
|
||||||
MessagesSettings: undefined
|
MessagesSettings: undefined
|
||||||
MessagesInbox: undefined
|
MessagesInbox: undefined
|
||||||
NotificationsActivityList: {posts: string}
|
NotificationsActivityList: {posts: string}
|
||||||
|
|||||||
@@ -178,6 +178,28 @@ export function isBskyStarterPackUrl(url: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
|
||||||
|
|
||||||
|
export function getChatInviteCodeFromUrl(url: string): string | undefined {
|
||||||
|
let pathname: string
|
||||||
|
if (isBskyAppUrl(url)) {
|
||||||
|
try {
|
||||||
|
pathname = new URL(url).pathname
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
} else if (url.startsWith('/')) {
|
||||||
|
pathname = url.split('?')[0].split('#')[0]
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return pathname.match(CHAT_INVITE_CODE_REGEX)?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBskyChatInviteUrl(url: string): boolean {
|
||||||
|
return getChatInviteCodeFromUrl(url) !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function isBskyDownloadUrl(url: string): boolean {
|
export function isBskyDownloadUrl(url: string): boolean {
|
||||||
if (isExternalUrl(url)) {
|
if (isExternalUrl(url)) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+566
-299
File diff suppressed because one or more lines are too long
@@ -74,6 +74,7 @@ export const router = new Router<AllNavigatableRoutes>({
|
|||||||
MessagesInbox: '/messages/inbox',
|
MessagesInbox: '/messages/inbox',
|
||||||
MessagesConversation: '/messages/:conversation',
|
MessagesConversation: '/messages/:conversation',
|
||||||
MessagesConversationSettings: '/messages/:conversation/settings',
|
MessagesConversationSettings: '/messages/:conversation/settings',
|
||||||
|
MessagesJoinRequests: '/messages/:conversation/requests',
|
||||||
// starter packs
|
// starter packs
|
||||||
Start: '/start/:name/:rkey',
|
Start: '/start/:name/:rkey',
|
||||||
StarterPackEdit: '/starter-pack/edit/:rkey',
|
StarterPackEdit: '/starter-pack/edit/:rkey',
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {plural} from '@lingui/core/macro'
|
import {plural} from '@lingui/core/macro'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||||
|
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function MembersAndRequests({
|
export function MembersAndRequests({
|
||||||
memberCount,
|
convo,
|
||||||
memberLimit,
|
|
||||||
requestCount,
|
requestCount,
|
||||||
hasMoreRequests,
|
hasMoreRequests,
|
||||||
isOwner,
|
isOwner,
|
||||||
}: {
|
}: {
|
||||||
memberCount: number
|
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||||
memberLimit: number
|
|
||||||
requestCount: number
|
requestCount: number
|
||||||
hasMoreRequests: boolean
|
hasMoreRequests: boolean
|
||||||
isOwner: boolean
|
isOwner: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const memberCount = convo.details.memberCount
|
||||||
|
const memberLimit = convo.details.memberLimit
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}>
|
<View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}>
|
||||||
@@ -42,8 +47,11 @@ export function MembersAndRequests({
|
|||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
label={l`View incoming group chat requests`}
|
label={l`View incoming group chat requests`}
|
||||||
style={[a.text_sm, a.text_right, a.font_semi_bold]}
|
style={[a.text_sm, a.text_right, a.font_semi_bold]}
|
||||||
// TODO Need to implement this. -dsb
|
{...createStaticClick(() => {
|
||||||
to="#">
|
navigation.navigate('MessagesJoinRequests', {
|
||||||
|
conversation: convo.view.id,
|
||||||
|
})
|
||||||
|
})}>
|
||||||
{hasMoreRequests
|
{hasMoreRequests
|
||||||
? l({
|
? l({
|
||||||
message: `${requestCount}+ requests`,
|
message: `${requestCount}+ requests`,
|
||||||
|
|||||||
@@ -247,8 +247,7 @@ function GroupSettings({
|
|||||||
case 'MEMBERS_AND_REQUESTS':
|
case 'MEMBERS_AND_REQUESTS':
|
||||||
return (
|
return (
|
||||||
<MembersAndRequests
|
<MembersAndRequests
|
||||||
memberCount={convo.details.memberCount}
|
convo={convo}
|
||||||
memberLimit={convo.details.memberLimit}
|
|
||||||
requestCount={requestCount}
|
requestCount={requestCount}
|
||||||
hasMoreRequests={!!hasMoreRequests}
|
hasMoreRequests={!!hasMoreRequests}
|
||||||
isOwner={isOwner}
|
isOwner={isOwner}
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {ImageBackground} from 'expo-image'
|
||||||
|
import {moderateProfile} from '@atproto/api'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
|
||||||
|
import {useActiveGroupChatJoinRequest} from '#/state/shell/landing'
|
||||||
|
import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
|
||||||
|
import {LogomarkWithType} from '#/view/icons/LogomarkWithType'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
|
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
|
||||||
|
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||||
|
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
const desktopDarkBg = require('../../../assets/images/chat-desktop-bg-dark.webp')
|
||||||
|
const desktopLightBg = require('../../../assets/images/chat-desktop-bg-light.webp')
|
||||||
|
const mobileDarkBg = require('../../../assets/images/chat-mobile-bg-dark.webp')
|
||||||
|
const mobileLightBg = require('../../../assets/images/chat-mobile-bg-light.webp')
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
setScreenState: (state: LoggedOutScreenState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JoinRequest({setScreenState}: Props) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const {gtMobile, gtTablet} = useBreakpoints()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
|
// Get code from context (logged-out only)
|
||||||
|
const contextJoinRequest = useActiveGroupChatJoinRequest()
|
||||||
|
const code = contextJoinRequest?.code
|
||||||
|
|
||||||
|
const {data, error} = useJoinLinkPreviewsQuery({
|
||||||
|
codes: code ? [code] : undefined,
|
||||||
|
hasSession: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDarkMode = t.name !== 'light'
|
||||||
|
const background = gtMobile
|
||||||
|
? isDarkMode
|
||||||
|
? desktopDarkBg
|
||||||
|
: desktopLightBg
|
||||||
|
: isDarkMode
|
||||||
|
? mobileDarkBg
|
||||||
|
: mobileLightBg
|
||||||
|
|
||||||
|
const requiresApproval = data?.joinLinkPreviews[0]?.requireApproval
|
||||||
|
const requiresFollow =
|
||||||
|
data?.joinLinkPreviews[0]?.joinRule === 'followedByOwner'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.util_screen_outer, a.w_full, t.atoms.bg_contrast_25]}>
|
||||||
|
<ImageBackground
|
||||||
|
source={background}
|
||||||
|
style={[a.util_screen_outer, a.w_full, a.flex_1, a.justify_center]}
|
||||||
|
contentFit={gtTablet ? 'contain' : 'cover'}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.util_screen_outer,
|
||||||
|
a.w_full,
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
]}>
|
||||||
|
{error ? (
|
||||||
|
<Wrapper>
|
||||||
|
<ChainLinkBrokenIcon fill={t.palette.primary_500} size="3xl" />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.mb_sm,
|
||||||
|
a.text_center,
|
||||||
|
a.text_lg,
|
||||||
|
a.font_semi_bold,
|
||||||
|
t.atoms.text,
|
||||||
|
]}>
|
||||||
|
{l`This invite link has expired`}
|
||||||
|
</Text>
|
||||||
|
<ActionButtons setScreenState={setScreenState} />
|
||||||
|
</Wrapper>
|
||||||
|
) : data && moderationOpts ? (
|
||||||
|
<Wrapper>
|
||||||
|
<AvatarBubbles
|
||||||
|
profiles={[
|
||||||
|
data.joinLinkPreviews[0].owner,
|
||||||
|
...Array(
|
||||||
|
Math.min(
|
||||||
|
3,
|
||||||
|
Math.max(0, data.joinLinkPreviews[0].memberCount - 1),
|
||||||
|
),
|
||||||
|
).fill(undefined),
|
||||||
|
]}
|
||||||
|
size={135}
|
||||||
|
/>
|
||||||
|
<View style={[a.gap_2xs]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_center,
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Group chat</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
|
<PersonGroupIcon
|
||||||
|
size="xs"
|
||||||
|
style={[a.mr_2xs, t.atoms.text_contrast_medium]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_center,
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans comment="The number of active group chat members out of the total number allowed.">
|
||||||
|
{data.joinLinkPreviews[0].memberCount}/
|
||||||
|
{data.joinLinkPreviews[0].memberLimit}
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_center,
|
||||||
|
a.text_4xl,
|
||||||
|
a.leading_tight,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text,
|
||||||
|
]}>
|
||||||
|
{data.joinLinkPreviews[0].name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.w_full]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_xs,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.mb_xs,
|
||||||
|
a.text_center,
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_snug,
|
||||||
|
a.font_semi_bold,
|
||||||
|
t.atoms.text,
|
||||||
|
a.max_w_full,
|
||||||
|
]}>
|
||||||
|
<Trans comment="The owner (creator) of a group chat.">
|
||||||
|
By{' '}
|
||||||
|
{createSanitizedDisplayName(
|
||||||
|
data.joinLinkPreviews[0].owner,
|
||||||
|
true,
|
||||||
|
moderateProfile(
|
||||||
|
data.joinLinkPreviews[0].owner,
|
||||||
|
moderationOpts,
|
||||||
|
).ui('displayName'),
|
||||||
|
)}
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<ProfileBadges
|
||||||
|
profile={data.joinLinkPreviews[0].owner}
|
||||||
|
size="sm"
|
||||||
|
style={{marginTop: -4}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_center,
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.max_w_full,
|
||||||
|
]}>
|
||||||
|
{sanitizeHandle(data.joinLinkPreviews[0].owner.handle, '@')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_center,
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
{requiresApproval
|
||||||
|
? l`Sign in to request access to this group chat.`
|
||||||
|
: l`Sign in to accept invite.`}{' '}
|
||||||
|
{requiresFollow &&
|
||||||
|
l`Only people ${createSanitizedDisplayName(
|
||||||
|
data.joinLinkPreviews[0].owner,
|
||||||
|
true,
|
||||||
|
moderateProfile(
|
||||||
|
data.joinLinkPreviews[0].owner,
|
||||||
|
moderationOpts,
|
||||||
|
).ui('displayName'),
|
||||||
|
)} follows can join.`}
|
||||||
|
</Text>
|
||||||
|
<ActionButtons setScreenState={setScreenState} />
|
||||||
|
</Wrapper>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</ImageBackground>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wrapper({children}: React.PropsWithChildren<unknown>) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const isDarkMode = t.name !== 'light'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LogomarkWithType
|
||||||
|
width={136}
|
||||||
|
fill={t.palette.primary_500}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
top: 40,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.zoom_fade_in,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_lg,
|
||||||
|
a.p_2xl,
|
||||||
|
a.pt_4xl,
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.shadow_xl,
|
||||||
|
isDarkMode ? [a.border, t.atoms.border_contrast_low] : null,
|
||||||
|
{
|
||||||
|
borderRadius: 48,
|
||||||
|
maxWidth: 320,
|
||||||
|
width: '90%',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButtons({
|
||||||
|
setScreenState,
|
||||||
|
}: {
|
||||||
|
setScreenState: (state: LoggedOutScreenState) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const isDarkMode = t.name !== 'light'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.w_full, a.gap_md]}>
|
||||||
|
<Button
|
||||||
|
testID="signInButton"
|
||||||
|
onPress={() => {
|
||||||
|
setScreenState(LoggedOutScreenState.S_Login)
|
||||||
|
}}
|
||||||
|
label={l`Sign in`}
|
||||||
|
accessibilityHint={l`Opens flow to sign in to your existing Bluesky account`}
|
||||||
|
size="large"
|
||||||
|
color="primary"
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Sign in</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
testID="createAccountButton"
|
||||||
|
onPress={() => {
|
||||||
|
setScreenState(LoggedOutScreenState.S_CreateAccount)
|
||||||
|
}}
|
||||||
|
label={l`Create new account`}
|
||||||
|
accessibilityHint={l`Opens flow to create a new Bluesky account`}
|
||||||
|
size="large"
|
||||||
|
color={isDarkMode ? 'secondary_inverted' : 'secondary'}
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Create account</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {
|
||||||
|
ChatBskyGroupApproveJoinRequest,
|
||||||
|
type ChatBskyGroupListJoinRequests,
|
||||||
|
ChatBskyGroupRejectJoinRequest,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {type InfiniteData, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||||
|
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||||
|
import {
|
||||||
|
type CommonNavigatorParams,
|
||||||
|
type NativeStackScreenProps,
|
||||||
|
type NavigationProp,
|
||||||
|
} from '#/lib/routes/types'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {ConvoProvider, useConvo} from '#/state/messages/convo'
|
||||||
|
import {ConvoStatus} from '#/state/messages/convo/types'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useJoinRequestMutation} from '#/state/queries/messages/join-requests'
|
||||||
|
import {
|
||||||
|
createListJoinRequestsQueryKey,
|
||||||
|
useListJoinRequestsQuery,
|
||||||
|
} from '#/state/queries/messages/list-join-requests'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {List} from '#/view/com/util/List'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
|
||||||
|
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||||
|
import {Error} from '#/components/Error'
|
||||||
|
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||||
|
import {KnownFollowers} from '#/components/KnownFollowers'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {InviteLinkDialog} from './components/InviteLinkDialog'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<
|
||||||
|
CommonNavigatorParams,
|
||||||
|
'MessagesJoinRequests'
|
||||||
|
>
|
||||||
|
|
||||||
|
export function MessagesJoinRequestsScreen(props: Props) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const aaCopy = useAgeAssuranceCopy()
|
||||||
|
return (
|
||||||
|
<AgeRestrictedScreen
|
||||||
|
screenTitle={l`Requests to join`}
|
||||||
|
infoText={aaCopy.chatsInfoText}>
|
||||||
|
<MessagesJoinRequestsScreenInner {...props} />
|
||||||
|
</AgeRestrictedScreen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessagesJoinRequestsScreenInner({route}: Props) {
|
||||||
|
const convoId = route.params.conversation
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Screen>
|
||||||
|
<ConvoProvider key={convoId} convoId={convoId}>
|
||||||
|
<JoinRequestsInner />
|
||||||
|
</ConvoProvider>
|
||||||
|
</Layout.Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JoinRequestsInner() {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const convoState = useConvo()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
if (convoState.status === ConvoStatus.Error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<Error
|
||||||
|
title={l`Something went wrong`}
|
||||||
|
message={l`We couldn’t load this conversation’s join requests`}
|
||||||
|
onRetry={() => convoState.error.retry()}
|
||||||
|
sideBorders={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!convoState.convo) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<View style={[a.flex_1, a.align_center, a.justify_center]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convoState.convo.kind !== 'group') {
|
||||||
|
return (
|
||||||
|
<Error
|
||||||
|
title={l`Wrong kind of conversation`}
|
||||||
|
message={l`This screen is only available for group conversations.`}
|
||||||
|
onGoBack={() => {
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.goBack()
|
||||||
|
} else {
|
||||||
|
navigation.replace('Messages', {animation: 'pop'})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <JoinRequestsList convo={convoState.convo} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function JoinRequestsList({
|
||||||
|
convo,
|
||||||
|
}: {
|
||||||
|
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const bottomBarOffset = useBottomBarOffset()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const inviteLinkControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
|
const getRemainingRequestCount = () => {
|
||||||
|
const data = queryClient.getQueryData<
|
||||||
|
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
|
||||||
|
>(createListJoinRequestsQueryKey({convoId: convo.view.id}))
|
||||||
|
return data?.pages.reduce((sum, page) => sum + page.requests.length, 0) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const [isPTRing, setIsPTRing] = useState(false)
|
||||||
|
const [footerHeight, setFooterHeight] = useState(0)
|
||||||
|
|
||||||
|
const owner = convo.primaryMember
|
||||||
|
const isOwner = !!owner && owner.did === currentAccount?.did
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: joinRequestsData,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useListJoinRequestsQuery({
|
||||||
|
convoId: convo.view.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items =
|
||||||
|
joinRequestsData?.pages.flatMap(page =>
|
||||||
|
page.requests.map(request => request.requestedBy),
|
||||||
|
) ?? []
|
||||||
|
const requestCount =
|
||||||
|
joinRequestsData?.pages.reduce(
|
||||||
|
(sum, page) => sum + page.requests.length,
|
||||||
|
0,
|
||||||
|
) ?? 0
|
||||||
|
|
||||||
|
const {mutate: approveJoinRequest, isPending: isApprovePending} =
|
||||||
|
useJoinRequestMutation('approve', convo.view.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
Toast.show(l`Request approved.`)
|
||||||
|
if (getRemainingRequestCount() < 1) {
|
||||||
|
navigation.replace('MessagesConversationSettings', {
|
||||||
|
conversation: convo.view.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: error => {
|
||||||
|
let errorMessage = l`Failed to accept join request`
|
||||||
|
if (isNetworkError(error)) {
|
||||||
|
errorMessage = l`A network error occurred. Please check your internet connection.`
|
||||||
|
} else if (
|
||||||
|
error instanceof ChatBskyGroupApproveJoinRequest.InvalidConvoError
|
||||||
|
) {
|
||||||
|
errorMessage = l`Conversation not found.`
|
||||||
|
} else if (
|
||||||
|
error instanceof ChatBskyGroupApproveJoinRequest.InsufficientRoleError
|
||||||
|
) {
|
||||||
|
errorMessage = l`Only admins can accept join requests.`
|
||||||
|
} else if (
|
||||||
|
error instanceof
|
||||||
|
ChatBskyGroupApproveJoinRequest.MemberLimitReachedError
|
||||||
|
) {
|
||||||
|
errorMessage = l`The member limit has been reached.`
|
||||||
|
}
|
||||||
|
Toast.show(errorMessage, {type: 'error'})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {mutate: rejectJoinRequest, isPending: isRejectPending} =
|
||||||
|
useJoinRequestMutation('reject', convo.view.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
Toast.show(l`Request ignored.`)
|
||||||
|
if (getRemainingRequestCount() < 1) {
|
||||||
|
navigation.replace('MessagesConversationSettings', {
|
||||||
|
conversation: convo.view.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: error => {
|
||||||
|
let errorMessage = l`Failed to ignore join request`
|
||||||
|
if (isNetworkError(error)) {
|
||||||
|
errorMessage = l`A network error occurred. Please check your internet connection.`
|
||||||
|
} else if (
|
||||||
|
error instanceof ChatBskyGroupRejectJoinRequest.InvalidConvoError
|
||||||
|
) {
|
||||||
|
errorMessage = l`Conversation not found.`
|
||||||
|
} else if (
|
||||||
|
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError
|
||||||
|
) {
|
||||||
|
errorMessage = l`Only admins can ignore join requests.`
|
||||||
|
}
|
||||||
|
Toast.show(errorMessage, {type: 'error'})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMutating = isApprovePending || isRejectPending
|
||||||
|
|
||||||
|
const renderItem = ({item}: {item: bsky.profile.AnyProfileView}) => {
|
||||||
|
if (!moderationOpts) return null
|
||||||
|
return (
|
||||||
|
<View style={[a.relative, a.flex_1, a.p_lg]}>
|
||||||
|
<View style={[a.flex_row, a.align_start, a.gap_md]}>
|
||||||
|
<ProfileCard.Link profile={item}>
|
||||||
|
<ProfileCard.Avatar
|
||||||
|
profile={item}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
size={44}
|
||||||
|
disabledPreview
|
||||||
|
/>
|
||||||
|
</ProfileCard.Link>
|
||||||
|
<View>
|
||||||
|
<ProfileCard.Name profile={item} moderationOpts={moderationOpts} />
|
||||||
|
<ProfileCard.Handle profile={item} />
|
||||||
|
<View style={[a.mt_xs]}>
|
||||||
|
<KnownFollowers
|
||||||
|
profile={item}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
minimal
|
||||||
|
showIfEmpty
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm, a.mt_md]}>
|
||||||
|
<AcceptButton
|
||||||
|
disabled={isMutating}
|
||||||
|
onPress={() => approveJoinRequest({member: item.did})}
|
||||||
|
/>
|
||||||
|
<RejectButton
|
||||||
|
disabled={isMutating}
|
||||||
|
onPress={() => rejectJoinRequest({member: item.did})}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<View
|
||||||
|
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.left_0,
|
||||||
|
a.right_0,
|
||||||
|
{bottom: 0},
|
||||||
|
a.px_xl,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
{
|
||||||
|
paddingTop: a.py_lg.paddingTop,
|
||||||
|
paddingBottom: a.py_lg.paddingBottom + bottomBarOffset,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
label={l`Edit invite link`}
|
||||||
|
size="large"
|
||||||
|
color="primary"
|
||||||
|
onPress={() => inviteLinkControl.open()}
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans context="button">Edit invite link</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
const onEndReached = async () => {
|
||||||
|
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||||
|
try {
|
||||||
|
await fetchNextPage()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to load more join requests', {message: err})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setIsPTRing(true)
|
||||||
|
try {
|
||||||
|
await refetch()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to refresh group chat requests', {message: err})
|
||||||
|
}
|
||||||
|
setIsPTRing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header count={requestCount} hasMoreRequests={hasNextPage} />
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.gap_sm,
|
||||||
|
a.p_lg,
|
||||||
|
]}>
|
||||||
|
<ErrorIcon size="3xl" fill={t.atoms.text_contrast_high.color} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.leading_snug,
|
||||||
|
a.text_center,
|
||||||
|
a.px_lg,
|
||||||
|
a.text_md,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
<Trans>Unable to fetch join requests.</Trans>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
label={l`Press to retry`}
|
||||||
|
onPress={() => void onRefresh()}
|
||||||
|
disabled={isPTRing}
|
||||||
|
size="large"
|
||||||
|
style={[a.mt_md]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Retry</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={isPTRing ? Loader : RetryIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showFooter = isOwner
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header count={requestCount} hasMoreRequests={hasNextPage} />
|
||||||
|
<List
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
|
||||||
|
renderItem={renderItem}
|
||||||
|
ListEmptyComponent={
|
||||||
|
isPending ? (
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.align_center, a.justify_center, a.py_4xl]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
contentContainerStyle={
|
||||||
|
showFooter ? {paddingBottom: footerHeight} : undefined
|
||||||
|
}
|
||||||
|
scrollIndicatorInsets={showFooter ? {bottom: footerHeight} : undefined}
|
||||||
|
refreshing={isPTRing}
|
||||||
|
onEndReached={() => void onEndReached()}
|
||||||
|
onRefresh={() => void onRefresh()}
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
sideBorders={false}
|
||||||
|
desktopFixedHeight
|
||||||
|
/>
|
||||||
|
{showFooter ? footer : null}
|
||||||
|
{owner && moderationOpts && (
|
||||||
|
<InviteLinkDialog
|
||||||
|
convo={convo}
|
||||||
|
control={inviteLinkControl}
|
||||||
|
owner={owner}
|
||||||
|
isOwner={isOwner}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({
|
||||||
|
count,
|
||||||
|
hasMoreRequests,
|
||||||
|
}: {
|
||||||
|
count?: number
|
||||||
|
hasMoreRequests?: boolean
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
return (
|
||||||
|
<Layout.Header.Outer>
|
||||||
|
<Layout.Header.BackButton />
|
||||||
|
<Layout.Header.Content>
|
||||||
|
<Layout.Header.TitleText>
|
||||||
|
{count === undefined ? (
|
||||||
|
<Trans>Requests to join</Trans>
|
||||||
|
) : hasMoreRequests ? (
|
||||||
|
l({
|
||||||
|
message: `${count}+ requests to join`,
|
||||||
|
comment:
|
||||||
|
'Displayed when there are more requests to join a group chat than have been loaded',
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Plural
|
||||||
|
value={count}
|
||||||
|
zero="No requests to join"
|
||||||
|
one="# request to join"
|
||||||
|
other="# requests to join"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Layout.Header.TitleText>
|
||||||
|
</Layout.Header.Content>
|
||||||
|
<Layout.Header.Slot />
|
||||||
|
</Layout.Header.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AcceptButton({
|
||||||
|
disabled,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
disabled?: boolean
|
||||||
|
onPress: () => void
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label={l`Accept join request`}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans comment="Accept a request to join a chat" context="button">
|
||||||
|
Accept
|
||||||
|
</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RejectButton({
|
||||||
|
disabled,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
disabled?: boolean
|
||||||
|
onPress: () => void
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label={l`Ignore join request`}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans comment="Ignore a request to join a chat" context="button">
|
||||||
|
Ignore
|
||||||
|
</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -297,8 +297,8 @@ export function InviteLinkDialog({
|
|||||||
const linkEnabled = joinLink?.enabledStatus === 'enabled'
|
const linkEnabled = joinLink?.enabledStatus === 'enabled'
|
||||||
const linkDisabled = joinLink?.enabledStatus === 'disabled'
|
const linkDisabled = joinLink?.enabledStatus === 'disabled'
|
||||||
const joinLinkURI = joinLink?.code
|
const joinLinkURI = joinLink?.code
|
||||||
? `https://bsky.app/chat/${joinLink.code}`
|
? `https://bsky.app/c/${joinLink.code}`
|
||||||
: 'https://bsky.app/chat'
|
: 'https://bsky.app/'
|
||||||
const createdAt = joinLink ? new Date(joinLink.createdAt) : null
|
const createdAt = joinLink ? new Date(joinLink.createdAt) : null
|
||||||
const currentOption =
|
const currentOption =
|
||||||
whoCanJoinOptions.find(
|
whoCanJoinOptions.find(
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
|||||||
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
||||||
import {DateDivider} from '#/components/dms/DateDivider'
|
import {DateDivider} from '#/components/dms/DateDivider'
|
||||||
import {MessageItem} from '#/components/dms/MessageItem'
|
import {MessageItem} from '#/components/dms/MessageItem'
|
||||||
|
import {MessageOverlays} from '#/components/dms/MessageOverlays'
|
||||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||||
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
|
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
|
||||||
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
|
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
|
||||||
@@ -498,127 +499,133 @@ export function MessagesList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InviteLinkDialogProvider convo={convoState.convo}>
|
<InviteLinkDialogProvider convo={convoState.convo}>
|
||||||
<KeyboardGestureArea
|
<MessageOverlays>
|
||||||
interpolator="ios"
|
<KeyboardGestureArea
|
||||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
interpolator="ios"
|
||||||
offset={Math.round(inputHeightJS)}
|
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||||
// slightly too buggy unfortunately, enable when possible
|
offset={Math.round(inputHeightJS)}
|
||||||
// textInputNativeID={textInputId}
|
// slightly too buggy unfortunately, enable when possible
|
||||||
style={[a.flex_1]}>
|
// textInputNativeID={textInputId}
|
||||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
style={[a.flex_1]}>
|
||||||
<Animated.View style={[a.flex_1, animatedListStyle]}>
|
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||||
<ScrollProvider onScroll={onScroll}>
|
<Animated.View style={[a.flex_1, animatedListStyle]}>
|
||||||
<List
|
<ScrollProvider onScroll={onScroll}>
|
||||||
ref={flatListRef}
|
<List
|
||||||
data={renderItems}
|
ref={flatListRef}
|
||||||
renderItem={renderItem}
|
data={renderItems}
|
||||||
keyExtractor={keyExtractor}
|
renderItem={renderItem}
|
||||||
disableFullWindowScroll={true}
|
keyExtractor={keyExtractor}
|
||||||
disableVirtualization={true}
|
disableFullWindowScroll={true}
|
||||||
// The extra two items account for the header and the footer components
|
disableVirtualization={true}
|
||||||
initialNumToRender={IS_NATIVE ? 32 : 62}
|
// The extra two items account for the header and the footer components
|
||||||
maxToRenderPerBatch={IS_WEB ? 32 : 62}
|
initialNumToRender={IS_NATIVE ? 32 : 62}
|
||||||
keyboardDismissMode="interactive"
|
maxToRenderPerBatch={IS_WEB ? 32 : 62}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardDismissMode="interactive"
|
||||||
maintainVisibleContentPosition={{minIndexForVisible: 0}}
|
keyboardShouldPersistTaps="handled"
|
||||||
removeClippedSubviews={false}
|
maintainVisibleContentPosition={{minIndexForVisible: 0}}
|
||||||
sideBorders={false}
|
removeClippedSubviews={false}
|
||||||
onContentSizeChange={onContentSizeChange}
|
sideBorders={false}
|
||||||
onStartReached={onStartReached}
|
onContentSizeChange={onContentSizeChange}
|
||||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
onStartReached={onStartReached}
|
||||||
showsVerticalScrollIndicator={!IS_ANDROID}
|
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||||
scrollEventThrottle={100}
|
showsVerticalScrollIndicator={!IS_ANDROID}
|
||||||
ListHeaderComponent={
|
scrollEventThrottle={100}
|
||||||
<>
|
ListHeaderComponent={
|
||||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
<>
|
||||||
{convoState.hasAllHistory ? (
|
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||||
convoState.convo?.kind === 'group' ? (
|
{convoState.hasAllHistory ? (
|
||||||
<MessagesListGroupInfoPanel convo={convoState.convo} />
|
convoState.convo?.kind === 'group' ? (
|
||||||
) : (
|
<MessagesListGroupInfoPanel convo={convoState.convo} />
|
||||||
<MessagesListInfoPanel convo={convoState.convo} />
|
) : (
|
||||||
)
|
<MessagesListInfoPanel convo={convoState.convo} />
|
||||||
) : null}
|
)
|
||||||
</>
|
) : null}
|
||||||
}
|
</>
|
||||||
// native only (prop is not supported on web)
|
}
|
||||||
renderScrollComponent={renderScrollComponent}
|
// native only (prop is not supported on web)
|
||||||
contentContainerStyle={{
|
renderScrollComponent={renderScrollComponent}
|
||||||
paddingBottom: platform({
|
contentContainerStyle={{
|
||||||
// ios is slightly larger as the input has no top padding
|
paddingBottom: platform({
|
||||||
ios: tokens.space.lg,
|
// ios is slightly larger as the input has no top padding
|
||||||
android: tokens.space.md,
|
ios: tokens.space.lg,
|
||||||
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
android: tokens.space.md,
|
||||||
}),
|
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
||||||
}}
|
}),
|
||||||
ListFooterComponent={
|
}}
|
||||||
<View
|
ListFooterComponent={
|
||||||
style={web({height: tokens.space.md + inputHeightJS})}
|
<View
|
||||||
onLayout={onFooterLayout}
|
style={web({height: tokens.space.md + inputHeightJS})}
|
||||||
/>
|
onLayout={onFooterLayout}
|
||||||
}
|
/>
|
||||||
style={[
|
}
|
||||||
web({
|
style={[
|
||||||
scrollbarWidth: 'thin',
|
web({
|
||||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
scrollbarWidth: 'thin',
|
||||||
scrollbarGutter: 'stable',
|
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||||
}),
|
scrollbarGutter: 'stable',
|
||||||
]}
|
}),
|
||||||
pointerEvents={!hasScrolled ? 'none' : 'auto'}
|
]}
|
||||||
contentInset={{top: transparentHeaderHeight}}
|
pointerEvents={!hasScrolled ? 'none' : 'auto'}
|
||||||
scrollIndicatorInsets={{top: transparentHeaderHeight}}
|
contentInset={{top: transparentHeaderHeight}}
|
||||||
/>
|
scrollIndicatorInsets={{top: transparentHeaderHeight}}
|
||||||
</ScrollProvider>
|
/>
|
||||||
</Animated.View>
|
</ScrollProvider>
|
||||||
<KeyboardStickyView
|
</Animated.View>
|
||||||
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
|
<KeyboardStickyView
|
||||||
onLayout={onInputLayout}
|
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
|
||||||
minimumOffset={bottomInset}
|
onLayout={onInputLayout}
|
||||||
offset={{
|
minimumOffset={bottomInset}
|
||||||
closed: platform({
|
offset={{
|
||||||
ios: tokens.space.lg, // hide bottom padding when closed
|
closed: platform({
|
||||||
default: 0,
|
ios: tokens.space.lg, // hide bottom padding when closed
|
||||||
}),
|
default: 0,
|
||||||
opened: 0,
|
}),
|
||||||
}}>
|
opened: 0,
|
||||||
{footer ?? (
|
}}>
|
||||||
<ConversationFooter
|
{footer ?? (
|
||||||
convoState={convoState}
|
<ConversationFooter
|
||||||
hasAcceptOverride={hasAcceptOverride}>
|
convoState={convoState}
|
||||||
{({loading}) =>
|
hasAcceptOverride={hasAcceptOverride}>
|
||||||
ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
|
{({loading}) =>
|
||||||
<MessageComposer
|
ax.features.enabled(
|
||||||
textInputId={textInputId}
|
ax.features.DmsNewMessageComposerEnable,
|
||||||
onSendMessage={(message: string) =>
|
) ? (
|
||||||
void onSendMessage(message)
|
<MessageComposer
|
||||||
}
|
textInputId={textInputId}
|
||||||
hasEmbed={!!embedUri}
|
onSendMessage={(message: string) =>
|
||||||
setEmbed={setEmbed}
|
void onSendMessage(message)
|
||||||
loading={loading}>
|
}
|
||||||
<MessageInputEmbed
|
hasEmbed={!!embedUri}
|
||||||
embedUri={embedUri}
|
|
||||||
setEmbed={setEmbed}
|
setEmbed={setEmbed}
|
||||||
/>
|
loading={loading}>
|
||||||
</MessageComposer>
|
<MessageInputEmbed
|
||||||
) : (
|
embedUri={embedUri}
|
||||||
<MessageInput
|
setEmbed={setEmbed}
|
||||||
textInputId={textInputId}
|
/>
|
||||||
onSendMessage={onSendMessage}
|
</MessageComposer>
|
||||||
hasEmbed={!!embedUri}
|
) : (
|
||||||
setEmbed={setEmbed}
|
<MessageInput
|
||||||
loading={loading}>
|
textInputId={textInputId}
|
||||||
<MessageInputEmbed
|
onSendMessage={onSendMessage}
|
||||||
embedUri={embedUri}
|
hasEmbed={!!embedUri}
|
||||||
setEmbed={setEmbed}
|
setEmbed={setEmbed}
|
||||||
/>
|
loading={loading}>
|
||||||
</MessageInput>
|
<MessageInputEmbed
|
||||||
)
|
embedUri={embedUri}
|
||||||
}
|
setEmbed={setEmbed}
|
||||||
</ConversationFooter>
|
/>
|
||||||
)}
|
</MessageInput>
|
||||||
</KeyboardStickyView>
|
)
|
||||||
</KeyboardGestureArea>
|
}
|
||||||
|
</ConversationFooter>
|
||||||
|
)}
|
||||||
|
</KeyboardStickyView>
|
||||||
|
</KeyboardGestureArea>
|
||||||
|
|
||||||
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
{newMessagesPill.show && (
|
||||||
|
<NewMessagesPill onPress={scrollToEndOnPress} />
|
||||||
|
)}
|
||||||
|
</MessageOverlays>
|
||||||
</InviteLinkDialogProvider>
|
</InviteLinkDialogProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,14 +43,11 @@ export function MessagesListGroupInfoPanel({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO Enable this once the feature is working end-to-end. -dsb
|
|
||||||
// const joinLink = groupConvo?.details.joinLink
|
|
||||||
const isJoinLinkEnabled = false
|
|
||||||
// (isOwner && groupConvo) ||
|
|
||||||
// (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
|
|
||||||
|
|
||||||
const isOwner = convo.primaryMember?.did === currentAccount?.did
|
const isOwner = convo.primaryMember?.did === currentAccount?.did
|
||||||
|
|
||||||
|
const isJoinLinkEnabled =
|
||||||
|
isOwner || convo.details.joinLink?.enabledStatus === 'enabled'
|
||||||
|
|
||||||
const members = (convo.members ?? []).filter(
|
const members = (convo.members ?? []).filter(
|
||||||
profile => profile.did !== currentAccount?.did,
|
profile => profile.did !== currentAccount?.did,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export function RequestListItem({
|
|||||||
const isDeletedAccount =
|
const isDeletedAccount =
|
||||||
!convo.primaryMember || convo.primaryMember.handle === 'missing.invalid'
|
!convo.primaryMember || convo.primaryMember.handle === 'missing.invalid'
|
||||||
|
|
||||||
|
const canAcceptRequest =
|
||||||
|
convo.kind === 'direct' || convo.details.lockStatus === 'unlocked'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.relative, a.flex_1]}>
|
<View style={[a.relative, a.flex_1]}>
|
||||||
<ChatListItem convo={convo.view} showMenu={false}>
|
<ChatListItem convo={convo.view} showMenu={false}>
|
||||||
@@ -65,7 +68,9 @@ export function RequestListItem({
|
|||||||
]}>
|
]}>
|
||||||
{convo.primaryMember && !isDeletedAccount ? (
|
{convo.primaryMember && !isDeletedAccount ? (
|
||||||
<>
|
<>
|
||||||
<AcceptChatButton convo={convo.view} currentScreen="list" />
|
{canAcceptRequest ? (
|
||||||
|
<AcceptChatButton convo={convo.view} currentScreen="list" />
|
||||||
|
) : null}
|
||||||
<RejectMenu
|
<RejectMenu
|
||||||
convo={convo.view}
|
convo={convo.view}
|
||||||
profile={convo.primaryMember}
|
profile={convo.primaryMember}
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import {preferencesQueryKey} from '#/state/queries/preferences'
|
|||||||
import {RQKEY as profileRQKey} from '#/state/queries/profile'
|
import {RQKEY as profileRQKey} from '#/state/queries/profile'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {useOnboardingDispatch} from '#/state/shell'
|
import {useOnboardingDispatch} from '#/state/shell'
|
||||||
import {useProgressGuideControls} from '#/state/shell/progress-guide'
|
|
||||||
import {
|
import {
|
||||||
useActiveStarterPack,
|
useActiveStarterPack,
|
||||||
useSetActiveStarterPack,
|
useSetActiveStarterPack,
|
||||||
} from '#/state/shell/starter-pack'
|
} from '#/state/shell/landing'
|
||||||
|
import {useProgressGuideControls} from '#/state/shell/progress-guide'
|
||||||
import {
|
import {
|
||||||
OnboardingControls,
|
OnboardingControls,
|
||||||
OnboardingHeaderSlot,
|
OnboardingHeaderSlot,
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function getAppIconName(icon: string | false): DynamicAppIcon.IconName {
|
|||||||
if (!icon || icon === 'DEFAULT') {
|
if (!icon || icon === 'DEFAULT') {
|
||||||
return 'default_light'
|
return 'default_light'
|
||||||
} else {
|
} else {
|
||||||
return icon as DynamicAppIcon.IconName
|
return icon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ function Group({
|
|||||||
values={[value]}
|
values={[value]}
|
||||||
maxSelections={1}
|
maxSelections={1}
|
||||||
onChange={vals => {
|
onChange={vals => {
|
||||||
if (vals[0]) onChange(vals[0] as DynamicAppIcon.IconName)
|
if (vals[0]) onChange(vals[0])
|
||||||
}}>
|
}}>
|
||||||
<View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
|
<View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -123,7 +123,11 @@ export function AutomationLabelSettingsScreen({}: Props) {
|
|||||||
paddingRight: 20, // helps visually center
|
paddingRight: 20, // helps visually center
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<UserAvatar size={42} avatar={profile.avatar} type="user" />
|
<UserAvatar
|
||||||
|
size={42}
|
||||||
|
avatar={profile.avatar}
|
||||||
|
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
/>
|
||||||
<View>
|
<View>
|
||||||
<View style={[a.flex_row, a.align_baseline]}>
|
<View style={[a.flex_row, a.align_baseline]}>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {FEEDBACK_FORM_URL} from '#/lib/constants'
|
|||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useServiceQuery} from '#/state/queries/service'
|
import {useServiceQuery} from '#/state/queries/service'
|
||||||
import {useStarterPackQuery} from '#/state/queries/starter-packs'
|
import {useStarterPackQuery} from '#/state/queries/starter-packs'
|
||||||
import {useActiveStarterPack} from '#/state/shell/starter-pack'
|
import {useActiveStarterPack} from '#/state/shell/landing'
|
||||||
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
|
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
|
||||||
import {
|
import {
|
||||||
initialState,
|
initialState,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {useStarterPackQuery} from '#/state/queries/starter-packs'
|
|||||||
import {
|
import {
|
||||||
useActiveStarterPack,
|
useActiveStarterPack,
|
||||||
useSetActiveStarterPack,
|
useSetActiveStarterPack,
|
||||||
} from '#/state/shell/starter-pack'
|
} from '#/state/shell/landing'
|
||||||
import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
|
import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
|
||||||
import {formatCount} from '#/view/com/util/numeric/format'
|
import {formatCount} from '#/view/com/util/numeric/format'
|
||||||
import {Logo} from '#/view/icons/Logo'
|
import {Logo} from '#/view/icons/Logo'
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ import {
|
|||||||
useStarterPackQuery,
|
useStarterPackQuery,
|
||||||
} from '#/state/queries/starter-packs'
|
} from '#/state/queries/starter-packs'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {useSetActiveStarterPack} from '#/state/shell/landing'
|
||||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||||
import {
|
import {
|
||||||
ProgressGuideAction,
|
ProgressGuideAction,
|
||||||
useProgressGuideControls,
|
useProgressGuideControls,
|
||||||
} from '#/state/shell/progress-guide'
|
} from '#/state/shell/progress-guide'
|
||||||
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
|
|
||||||
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
|
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
|
||||||
import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
|
import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
|
||||||
import {bulkWriteFollows} from '#/screens/Onboarding/util'
|
import {bulkWriteFollows} from '#/screens/Onboarding/util'
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ import {
|
|||||||
import {useEventListener} from 'expo'
|
import {useEventListener} from 'expo'
|
||||||
import {type VideoPlayer} from 'expo-video'
|
import {type VideoPlayer} from 'expo-video'
|
||||||
|
|
||||||
import {tokens} from '#/alf'
|
import {atoms as a, tokens} from '#/alf'
|
||||||
import {atoms as a} from '#/alf'
|
|
||||||
import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils'
|
import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
|||||||
+3
-11
@@ -13,7 +13,6 @@ import {
|
|||||||
} from 'expo-image-manipulator'
|
} from 'expo-image-manipulator'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
import {POST_IMG_MAX} from '#/lib/constants'
|
|
||||||
import {getImageDim} from '#/lib/media/manip'
|
import {getImageDim} from '#/lib/media/manip'
|
||||||
import {openCropper} from '#/lib/media/picker'
|
import {openCropper} from '#/lib/media/picker'
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||||
@@ -202,19 +201,12 @@ export function resetImageManipulation(
|
|||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function compressImage(
|
export async function compressImage(img: ComposerImage): Promise<PickerImage> {
|
||||||
img: ComposerImage,
|
|
||||||
options?: {
|
|
||||||
highResolution?: boolean
|
|
||||||
increasedBlobSizeLimit?: boolean
|
|
||||||
},
|
|
||||||
): Promise<PickerImage> {
|
|
||||||
const source = img.transformed || img.source
|
const source = img.transformed || img.source
|
||||||
const highResolution = options?.highResolution ?? false
|
|
||||||
|
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
let maxDimension = highResolution ? 4000 : POST_IMG_MAX.width
|
let maxDimension = 4000
|
||||||
let maxBytes = options?.increasedBlobSizeLimit ? 2000000 : POST_IMG_MAX.size
|
let maxBytes = 2000000
|
||||||
|
|
||||||
let minQualityPercentage = 0
|
let minQualityPercentage = 0
|
||||||
let maxQualityPercentage = 101 // exclusive
|
let maxQualityPercentage = 101 // exclusive
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {createContext, useContext, useMemo} from 'react'
|
import {createContext, useContext, useMemo} from 'react'
|
||||||
import {BskyAgent, type ModerationOpts} from '@atproto/api'
|
import {AtpAgent, type ModerationOpts} from '@atproto/api'
|
||||||
|
|
||||||
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
|
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
|
||||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||||
@@ -43,7 +43,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
...moderationPrefs,
|
...moderationPrefs,
|
||||||
labelers: moderationPrefs.labelers.length
|
labelers: moderationPrefs.labelers.length
|
||||||
? moderationPrefs.labelers
|
? moderationPrefs.labelers
|
||||||
: BskyAgent.appLabelers.map(did => ({
|
: AtpAgent.appLabelers.map(did => ({
|
||||||
did,
|
did,
|
||||||
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {AtpAgent} from '@atproto/api'
|
||||||
|
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {STALE} from '#/state/queries/index'
|
||||||
|
import {createQueryKey} from '#/state/queries/util'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
|
||||||
|
const joinLinkPreviewQueryKeyRoot = 'join-link-preview'
|
||||||
|
|
||||||
|
export const createJoinLinkPreviewQueryKey = (args: {
|
||||||
|
codes: string[]
|
||||||
|
hasSession: boolean
|
||||||
|
}) =>
|
||||||
|
createQueryKey(joinLinkPreviewQueryKeyRoot, args, {
|
||||||
|
persistedVersion: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useJoinLinkPreviewsQuery({
|
||||||
|
codes,
|
||||||
|
hasSession,
|
||||||
|
}: {
|
||||||
|
codes?: string[]
|
||||||
|
hasSession: boolean
|
||||||
|
}) {
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: createJoinLinkPreviewQueryKey({codes: codes ?? [], hasSession}),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!codes) throw new Error('No invite code')
|
||||||
|
try {
|
||||||
|
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
|
||||||
|
const res = hasSession
|
||||||
|
? await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||||
|
{codes},
|
||||||
|
{headers: DM_SERVICE_HEADERS},
|
||||||
|
)
|
||||||
|
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
|
||||||
|
return res.data
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch join link preview', {safeMessage: error})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: codes != null && codes.length > 0,
|
||||||
|
staleTime: STALE.SECONDS.FIFTEEN,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrefetchJoinLinkPreviews() {
|
||||||
|
const agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => {
|
||||||
|
return queryClient.prefetchQuery({
|
||||||
|
queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}),
|
||||||
|
queryFn: async () => {
|
||||||
|
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
|
||||||
|
const res = hasSession
|
||||||
|
? await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||||
|
{codes},
|
||||||
|
{headers: DM_SERVICE_HEADERS},
|
||||||
|
)
|
||||||
|
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
staleTime: STALE.SECONDS.FIFTEEN,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import {
|
||||||
|
type ChatBskyActorDefs,
|
||||||
|
type ChatBskyGroupApproveJoinRequest,
|
||||||
|
type ChatBskyGroupListJoinRequests,
|
||||||
|
type ChatBskyGroupRejectJoinRequest,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {
|
||||||
|
type InfiniteData,
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {listConvoMembersQueryKey} from './list-convo-members'
|
||||||
|
import {createListJoinRequestsQueryKey} from './list-join-requests'
|
||||||
|
|
||||||
|
type JoinRequestAction = 'approve' | 'reject'
|
||||||
|
|
||||||
|
type JoinRequestOutput<A extends JoinRequestAction> = A extends 'approve'
|
||||||
|
? ChatBskyGroupApproveJoinRequest.OutputSchema
|
||||||
|
: ChatBskyGroupRejectJoinRequest.OutputSchema
|
||||||
|
|
||||||
|
export function useJoinRequestMutation<A extends JoinRequestAction>(
|
||||||
|
action: A,
|
||||||
|
convoId: string | undefined,
|
||||||
|
{
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess?: (data: JoinRequestOutput<A>) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({member}: {member: string}) => {
|
||||||
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
const endpoint =
|
||||||
|
action === 'approve'
|
||||||
|
? agent.chat.bsky.group.approveJoinRequest
|
||||||
|
: agent.chat.bsky.group.rejectJoinRequest
|
||||||
|
const {data} = await endpoint(
|
||||||
|
{convoId, member},
|
||||||
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
|
)
|
||||||
|
return data as JoinRequestOutput<A>
|
||||||
|
},
|
||||||
|
onMutate: ({member}) => {
|
||||||
|
if (!convoId) return
|
||||||
|
|
||||||
|
const requestsKey = createListJoinRequestsQueryKey({convoId})
|
||||||
|
const prevRequests =
|
||||||
|
queryClient.getQueryData<
|
||||||
|
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
|
||||||
|
>(requestsKey)
|
||||||
|
|
||||||
|
const requestedByProfile = prevRequests?.pages
|
||||||
|
.flatMap(page => page.requests)
|
||||||
|
.find(request => request.requestedBy.did === member)?.requestedBy
|
||||||
|
|
||||||
|
queryClient.setQueryData<
|
||||||
|
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
|
||||||
|
>(requestsKey, prev => {
|
||||||
|
if (!prev?.pages) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: prev.pages.map(page => ({
|
||||||
|
...page,
|
||||||
|
requests: page.requests.filter(
|
||||||
|
request => request.requestedBy.did !== member,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let prevMembers: ChatBskyActorDefs.ProfileViewBasic[] | undefined
|
||||||
|
if (action === 'approve' && requestedByProfile) {
|
||||||
|
const membersKey = listConvoMembersQueryKey(convoId)
|
||||||
|
prevMembers =
|
||||||
|
queryClient.getQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
|
||||||
|
membersKey,
|
||||||
|
)
|
||||||
|
queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
|
||||||
|
membersKey,
|
||||||
|
prev => {
|
||||||
|
if (!prev) return prev
|
||||||
|
if (prev.some(m => m.did === member)) return prev
|
||||||
|
return [...prev, requestedByProfile]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {prevRequests, prevMembers}
|
||||||
|
},
|
||||||
|
onSuccess: data => {
|
||||||
|
if (convoId) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: createListJoinRequestsQueryKey({convoId}),
|
||||||
|
})
|
||||||
|
if (action === 'approve') {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: listConvoMembersQueryKey(convoId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: (error, _variables, context) => {
|
||||||
|
logger.error(error)
|
||||||
|
if (convoId && context?.prevRequests) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
createListJoinRequestsQueryKey({convoId}),
|
||||||
|
context.prevRequests,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (convoId && action === 'approve' && context?.prevMembers) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
listConvoMembersQueryKey(convoId),
|
||||||
|
context.prevMembers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onError?.(error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {type ChatBskyGroupRequestJoin} from '@atproto/api'
|
||||||
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
|
||||||
|
export function useRequestJoinGroupChat({
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess?: (data: ChatBskyGroupRequestJoin.OutputSchema) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
} = {}) {
|
||||||
|
const agent = useAgent()
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({code}: {code: string}) => {
|
||||||
|
if (!hasSession) throw new Error('Must be logged in to join')
|
||||||
|
if (!code) throw new Error('No invite code')
|
||||||
|
|
||||||
|
const res = await agent.chat.bsky.group.requestJoin(
|
||||||
|
{code},
|
||||||
|
{headers: DM_SERVICE_HEADERS},
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
onSuccess: data => {
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: error => {
|
||||||
|
logger.error('Failed to join group chat', {safeMessage: error})
|
||||||
|
onError?.(error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {type ChatBskyGroupWithdrawJoinRequest} from '@atproto/api'
|
||||||
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
|
||||||
|
export function useWithdrawJoinGroupChatRequest({
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSuccess?: (data: ChatBskyGroupWithdrawJoinRequest.OutputSchema) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
} = {}) {
|
||||||
|
const agent = useAgent()
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({convoId}: {convoId: string}) => {
|
||||||
|
if (!hasSession)
|
||||||
|
throw new Error('Must be logged in to withdraw a join request')
|
||||||
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
|
||||||
|
const res = await agent.chat.bsky.group.withdrawJoinRequest(
|
||||||
|
{convoId},
|
||||||
|
{headers: DM_SERVICE_HEADERS},
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
onSuccess: data => {
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: error => {
|
||||||
|
logger.error('Failed to withdraw join request', {safeMessage: error})
|
||||||
|
onError?.(error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -298,7 +298,7 @@ export function* findAllPostsInQueryData(
|
|||||||
if (AppBskyFeedDefs.isPostView(item.subject)) {
|
if (AppBskyFeedDefs.isPostView(item.subject)) {
|
||||||
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
||||||
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
|
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
|
||||||
yield embedViewRecordToPostView(quotedPost!)
|
yield embedViewRecordToPostView(quotedPost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function parseAppNux(nux: AppBskyActorDefs.Nux): AppNux | undefined {
|
|||||||
|
|
||||||
export function serializeAppNux(nux: AppNux): AppBskyActorDefs.Nux {
|
export function serializeAppNux(nux: AppNux): AppBskyActorDefs.Nux {
|
||||||
const {data, ...rest} = nux
|
const {data, ...rest} = nux
|
||||||
const schema = NuxSchemas[nux.id as Nux]
|
const schema = NuxSchemas[nux.id]
|
||||||
|
|
||||||
const result: AppBskyActorDefs.Nux = {
|
const result: AppBskyActorDefs.Nux = {
|
||||||
...rest,
|
...rest,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function useSuggestedFollowsByActorWithDismiss({
|
|||||||
|
|
||||||
const profiles = useMemo(() => {
|
const profiles = useMemo(() => {
|
||||||
return (data?.suggestions ?? []).map(profile => ({
|
return (data?.suggestions ?? []).map(profile => ({
|
||||||
actor: profile as bsky.profile.AnyProfileView,
|
actor: profile,
|
||||||
recId: data?.recId,
|
recId: data?.recId,
|
||||||
}))
|
}))
|
||||||
}, [data?.suggestions, data?.recId])
|
}, [data?.suggestions, data?.recId])
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export function sortAndAnnotateThreadItems(
|
|||||||
* `repliesSeenCounter` later on, since `repliesSeenCounter`
|
* `repliesSeenCounter` later on, since `repliesSeenCounter`
|
||||||
* is 1-indexed and `replyIndex` is 0-indexed.
|
* is 1-indexed and `replyIndex` is 0-indexed.
|
||||||
*/
|
*/
|
||||||
childMetadata!.replyIndex =
|
childMetadata.replyIndex =
|
||||||
childParentMetadata.repliesSeenCounter
|
childParentMetadata.repliesSeenCounter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {createContext, useContext, useState} from 'react'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
|
||||||
|
type StarterPackLanding = {
|
||||||
|
type: 'starterpack'
|
||||||
|
uri: string
|
||||||
|
isClip?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupChatJoinRequestLanding = {
|
||||||
|
type: 'groupchat'
|
||||||
|
uri: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LandingType = StarterPackLanding | GroupChatJoinRequestLanding | undefined
|
||||||
|
|
||||||
|
type SetContext = (v: LandingType) => void
|
||||||
|
|
||||||
|
const stateContext = createContext<LandingType>(undefined)
|
||||||
|
stateContext.displayName = 'ActiveLandingStateContext'
|
||||||
|
const setContext = createContext<SetContext>((_: LandingType) => {})
|
||||||
|
setContext.displayName = 'ActiveLandingSetContext'
|
||||||
|
|
||||||
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
|
const [state, setState] = useState<LandingType>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<stateContext.Provider value={state}>
|
||||||
|
<setContext.Provider value={setState}>{children}</setContext.Provider>
|
||||||
|
</stateContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core hooks
|
||||||
|
export const useActiveLanding = () => useContext(stateContext)
|
||||||
|
export const useSetActiveLanding = () => useContext(setContext)
|
||||||
|
|
||||||
|
// Filtered hooks for convenience
|
||||||
|
export const useActiveStarterPack = () => {
|
||||||
|
const landing = useActiveLanding()
|
||||||
|
return landing?.type === 'starterpack' ? landing : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSetActiveStarterPack = () => {
|
||||||
|
const setLanding = useSetActiveLanding()
|
||||||
|
const currentLanding = useActiveLanding()
|
||||||
|
return (pack: {uri: string; isClip?: boolean} | undefined) => {
|
||||||
|
if (!pack) {
|
||||||
|
setLanding(undefined)
|
||||||
|
} else {
|
||||||
|
if (currentLanding && currentLanding.type !== 'starterpack') {
|
||||||
|
logger.debug(
|
||||||
|
`[landing] Replacing ${currentLanding.type} landing with starterpack`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setLanding({type: 'starterpack', ...pack})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useActiveGroupChatJoinRequest = () => {
|
||||||
|
const landing = useActiveLanding()
|
||||||
|
return landing?.type === 'groupchat' ? landing : undefined
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {createContext, useContext, useMemo, useState} from 'react'
|
import {createContext, useContext, useMemo, useState} from 'react'
|
||||||
|
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useActiveStarterPack} from '#/state/shell/starter-pack'
|
import {useActiveLanding} from '#/state/shell/landing'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
@@ -26,7 +26,12 @@ type Controls = {
|
|||||||
/**
|
/**
|
||||||
* The did of the account to populate the login form with.
|
* The did of the account to populate the login form with.
|
||||||
*/
|
*/
|
||||||
requestedAccount?: (string & {}) | 'none' | 'new' | 'starterpack'
|
requestedAccount?:
|
||||||
|
| (string & {})
|
||||||
|
| 'none'
|
||||||
|
| 'new'
|
||||||
|
| 'starterpack'
|
||||||
|
| 'groupchat'
|
||||||
}) => void
|
}) => void
|
||||||
/**
|
/**
|
||||||
* Clears the requested account so that next time the logged out view is
|
* Clears the requested account so that next time the logged out view is
|
||||||
@@ -48,17 +53,34 @@ const ControlsContext = createContext<Controls>({
|
|||||||
})
|
})
|
||||||
ControlsContext.displayName = 'LoggedOutControlsContext'
|
ControlsContext.displayName = 'LoggedOutControlsContext'
|
||||||
|
|
||||||
|
function getRequestedAccountFromLanding(
|
||||||
|
landing: ReturnType<typeof useActiveLanding>,
|
||||||
|
hasSession: boolean,
|
||||||
|
): string | undefined {
|
||||||
|
if (hasSession || !landing) return undefined
|
||||||
|
|
||||||
|
switch (landing.type) {
|
||||||
|
case 'starterpack':
|
||||||
|
return IS_WEB ? 'starterpack' : 'new'
|
||||||
|
case 'groupchat':
|
||||||
|
return 'groupchat'
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const activeStarterPack = useActiveStarterPack()
|
const activeLanding = useActiveLanding()
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
const shouldShowStarterPack = Boolean(activeStarterPack?.uri) && !hasSession
|
|
||||||
|
const requestedAccount = getRequestedAccountFromLanding(
|
||||||
|
activeLanding,
|
||||||
|
hasSession,
|
||||||
|
)
|
||||||
|
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
showLoggedOut: shouldShowStarterPack,
|
showLoggedOut: Boolean(requestedAccount),
|
||||||
requestedAccountSwitchTo: shouldShowStarterPack
|
requestedAccountSwitchTo: requestedAccount,
|
||||||
? IS_WEB
|
|
||||||
? 'starterpack'
|
|
||||||
: 'new'
|
|
||||||
: undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const controls = useMemo<Controls>(
|
const controls = useMemo<Controls>(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
|||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {profilesQueryKey} from '#/state/queries/profile'
|
import {profilesQueryKey} from '#/state/queries/profile'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {useSetActiveLanding} from '#/state/shell/landing'
|
||||||
import {
|
import {
|
||||||
useLoggedOutView,
|
useLoggedOutView,
|
||||||
useLoggedOutViewControls,
|
useLoggedOutViewControls,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
import {useEnableMinimalShellMode} from '#/state/shell/minimal-mode'
|
import {useEnableMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||||
import {Login} from '#/screens/Login'
|
import {Login} from '#/screens/Login'
|
||||||
|
import {JoinRequest} from '#/screens/Messages/JoinRequest'
|
||||||
import {Signup} from '#/screens/Signup'
|
import {Signup} from '#/screens/Signup'
|
||||||
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
|
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
|
||||||
import {atoms as a, native, tokens, useTheme} from '#/alf'
|
import {atoms as a, native, tokens, useTheme} from '#/alf'
|
||||||
@@ -29,9 +31,26 @@ enum ScreenState {
|
|||||||
S_Login,
|
S_Login,
|
||||||
S_CreateAccount,
|
S_CreateAccount,
|
||||||
S_StarterPack,
|
S_StarterPack,
|
||||||
|
S_GroupChatJoinRequest,
|
||||||
}
|
}
|
||||||
export {ScreenState as LoggedOutScreenState}
|
export {ScreenState as LoggedOutScreenState}
|
||||||
|
|
||||||
|
function getInitialScreenState(requestedAccountSwitchTo?: string): ScreenState {
|
||||||
|
switch (requestedAccountSwitchTo) {
|
||||||
|
case 'new':
|
||||||
|
return ScreenState.S_CreateAccount
|
||||||
|
case 'starterpack':
|
||||||
|
return ScreenState.S_StarterPack
|
||||||
|
case 'groupchat':
|
||||||
|
return ScreenState.S_GroupChatJoinRequest
|
||||||
|
case undefined:
|
||||||
|
return ScreenState.S_LoginOrCreateAccount
|
||||||
|
default:
|
||||||
|
// DID or other string = login with that account
|
||||||
|
return ScreenState.S_Login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
@@ -39,18 +58,11 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
|||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
useEnableMinimalShellMode()
|
useEnableMinimalShellMode()
|
||||||
const {requestedAccountSwitchTo} = useLoggedOutView()
|
const {requestedAccountSwitchTo} = useLoggedOutView()
|
||||||
const [screenState, setScreenState] = useState<ScreenState>(() => {
|
const initialScreenState = getInitialScreenState(requestedAccountSwitchTo)
|
||||||
if (requestedAccountSwitchTo === 'new') {
|
const [screenState, setScreenState] =
|
||||||
return ScreenState.S_CreateAccount
|
useState<ScreenState>(initialScreenState)
|
||||||
} else if (requestedAccountSwitchTo === 'starterpack') {
|
|
||||||
return ScreenState.S_StarterPack
|
|
||||||
} else if (requestedAccountSwitchTo != null) {
|
|
||||||
return ScreenState.S_Login
|
|
||||||
} else {
|
|
||||||
return ScreenState.S_LoginOrCreateAccount
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const {clearRequestedAccount} = useLoggedOutViewControls()
|
const {clearRequestedAccount} = useLoggedOutViewControls()
|
||||||
|
const setActiveLanding = useSetActiveLanding()
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {accounts} = useSession()
|
const {accounts} = useSession()
|
||||||
@@ -73,7 +85,9 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
clearRequestedAccount()
|
clearRequestedAccount()
|
||||||
}, [clearRequestedAccount, onDismiss])
|
// Clear landing context when user dismisses the modal
|
||||||
|
setActiveLanding(undefined)
|
||||||
|
}, [clearRequestedAccount, onDismiss, setActiveLanding])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -107,6 +121,8 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
|||||||
|
|
||||||
{screenState === ScreenState.S_StarterPack ? (
|
{screenState === ScreenState.S_StarterPack ? (
|
||||||
<LandingScreen setScreenState={setScreenState} />
|
<LandingScreen setScreenState={setScreenState} />
|
||||||
|
) : screenState === ScreenState.S_GroupChatJoinRequest ? (
|
||||||
|
<JoinRequest setScreenState={setScreenState} />
|
||||||
) : screenState === ScreenState.S_LoginOrCreateAccount ? (
|
) : screenState === ScreenState.S_LoginOrCreateAccount ? (
|
||||||
<SplashScreen
|
<SplashScreen
|
||||||
onPressSignin={() => {
|
onPressSignin={() => {
|
||||||
@@ -122,17 +138,12 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
|||||||
{screenState === ScreenState.S_Login ? (
|
{screenState === ScreenState.S_Login ? (
|
||||||
<Login
|
<Login
|
||||||
onPressBack={() => {
|
onPressBack={() => {
|
||||||
setScreenState(ScreenState.S_LoginOrCreateAccount)
|
setScreenState(initialScreenState)
|
||||||
clearRequestedAccount()
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{screenState === ScreenState.S_CreateAccount ? (
|
{screenState === ScreenState.S_CreateAccount ? (
|
||||||
<Signup
|
<Signup onPressBack={() => setScreenState(initialScreenState)} />
|
||||||
onPressBack={() =>
|
|
||||||
setScreenState(ScreenState.S_LoginOrCreateAccount)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -877,24 +877,12 @@ export const ComposePost = ({
|
|||||||
try {
|
try {
|
||||||
logger.info(`composer: posting...`)
|
logger.info(`composer: posting...`)
|
||||||
postUri = (
|
postUri = (
|
||||||
await apilib.post(
|
await apilib.post(agent, queryClient, {
|
||||||
agent,
|
thread: filteredThread,
|
||||||
queryClient,
|
replyTo: replyTo?.uri,
|
||||||
{
|
onStateChange: setPublishingStage,
|
||||||
thread: filteredThread,
|
langs: currentLanguages,
|
||||||
replyTo: replyTo?.uri,
|
})
|
||||||
onStateChange: setPublishingStage,
|
|
||||||
langs: currentLanguages,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
highResolutionImages: ax.features.enabled(
|
|
||||||
ax.features.ImageUploadsHighResolution,
|
|
||||||
),
|
|
||||||
increasedBlobSizeLimit: ax.features.enabled(
|
|
||||||
ax.features.ImageUploadsBlobSize2mbEnabled,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).uris[0]
|
).uris[0]
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ export const ExternalEmbedLink = ({
|
|||||||
uri,
|
uri,
|
||||||
description:
|
description:
|
||||||
data.view?.external?.description || data.description,
|
data.view?.external?.description || data.description,
|
||||||
thumb: data.view?.external?.thumb || data.thumb?.source.path,
|
// prefer opengraph data to atproto record-derived image
|
||||||
|
thumb: data.thumb?.source.path || data.view?.external?.thumb,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -470,11 +470,11 @@ export async function draftToComposerPosts(
|
|||||||
height,
|
height,
|
||||||
mime: 'image/jpeg',
|
mime: 'image/jpeg',
|
||||||
},
|
},
|
||||||
} as ComposerImage
|
} satisfies ComposerImage
|
||||||
})
|
})
|
||||||
|
|
||||||
const images = (await Promise.all(imagePromises)).filter(
|
const images = (await Promise.all(imagePromises)).filter(
|
||||||
(img): img is ComposerImage => img !== null,
|
(img): img is NonNullable<typeof img> => img !== null,
|
||||||
)
|
)
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
embed.media = {type: 'images', images}
|
embed.media = {type: 'images', images}
|
||||||
@@ -511,7 +511,7 @@ export async function draftToComposerPosts(
|
|||||||
tinygif: mediaObject,
|
tinygif: mediaObject,
|
||||||
preview: mediaObject,
|
preview: mediaObject,
|
||||||
},
|
},
|
||||||
} as Gif,
|
},
|
||||||
alt: gifData.alt,
|
alt: gifData.alt,
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1093,6 +1093,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
|||||||
<MediaPreview.Embed
|
<MediaPreview.Embed
|
||||||
embed={post.embed}
|
embed={post.embed}
|
||||||
style={styles.additionalPostImages}
|
style={styles.additionalPostImages}
|
||||||
|
peekable
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function TestCtrls() {
|
|||||||
accessibilityLabel="Text input field"
|
accessibilityLabel="Text input field"
|
||||||
accessibilityHint="Enter proxy header"
|
accessibilityHint="Enter proxy header"
|
||||||
testID="e2eProxyHeaderInput"
|
testID="e2eProxyHeaderInput"
|
||||||
onChangeText={val => setProxyHeader(val as any)}
|
onChangeText={val => setProxyHeader(val)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||||
import {StackActions} from '@react-navigation/native'
|
import {StackActions} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
|
||||||
import {
|
import {
|
||||||
type DebouncedNavigationProp,
|
type DebouncedNavigationProp,
|
||||||
useNavigationDeduped,
|
useNavigationDeduped,
|
||||||
@@ -21,6 +22,7 @@ import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
|||||||
import {getTabState, TabState} from '#/lib/routes/helpers'
|
import {getTabState, TabState} from '#/lib/routes/helpers'
|
||||||
import {
|
import {
|
||||||
convertBskyAppUrlIfNeeded,
|
convertBskyAppUrlIfNeeded,
|
||||||
|
getChatInviteCodeFromUrl,
|
||||||
isExternalUrl,
|
isExternalUrl,
|
||||||
linkRequiresWarning,
|
linkRequiresWarning,
|
||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
@@ -81,6 +83,7 @@ export const Link = memo(function Link({
|
|||||||
const navigation = useNavigationDeduped()
|
const navigation = useNavigationDeduped()
|
||||||
const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
|
const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
|
||||||
const openLink = useOpenLink()
|
const openLink = useOpenLink()
|
||||||
|
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||||
|
|
||||||
const onPress = useCallback(
|
const onPress = useCallback(
|
||||||
(e?: Event) => {
|
(e?: Event) => {
|
||||||
@@ -92,11 +95,20 @@ export const Link = memo(function Link({
|
|||||||
sanitizeUrl(href),
|
sanitizeUrl(href),
|
||||||
navigationAction,
|
navigationAction,
|
||||||
openLink,
|
openLink,
|
||||||
|
groupChatJoinIntent,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[closeModal, navigation, navigationAction, href, openLink, onBeforePress],
|
[
|
||||||
|
closeModal,
|
||||||
|
navigation,
|
||||||
|
navigationAction,
|
||||||
|
href,
|
||||||
|
openLink,
|
||||||
|
onBeforePress,
|
||||||
|
groupChatJoinIntent,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
const accessibilityActionsWithActivate = [
|
const accessibilityActionsWithActivate = [
|
||||||
@@ -196,6 +208,7 @@ export const TextLink = memo(function TextLink({
|
|||||||
const {closeModal} = useModalControls()
|
const {closeModal} = useModalControls()
|
||||||
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
||||||
const openLink = useOpenLink()
|
const openLink = useOpenLink()
|
||||||
|
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||||
|
|
||||||
if (!disableMismatchWarning && typeof text !== 'string') {
|
if (!disableMismatchWarning && typeof text !== 'string') {
|
||||||
console.error('Unable to detect mismatching label')
|
console.error('Unable to detect mismatching label')
|
||||||
@@ -238,6 +251,7 @@ export const TextLink = memo(function TextLink({
|
|||||||
sanitizeUrl(href),
|
sanitizeUrl(href),
|
||||||
navigationAction,
|
navigationAction,
|
||||||
openLink,
|
openLink,
|
||||||
|
groupChatJoinIntent,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -252,6 +266,7 @@ export const TextLink = memo(function TextLink({
|
|||||||
navigationAction,
|
navigationAction,
|
||||||
openLink,
|
openLink,
|
||||||
linkWarningDialogControl,
|
linkWarningDialogControl,
|
||||||
|
groupChatJoinIntent,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
const hrefAttrs = useMemo(() => {
|
const hrefAttrs = useMemo(() => {
|
||||||
@@ -373,6 +388,7 @@ function onPressInner(
|
|||||||
href: string,
|
href: string,
|
||||||
navigationAction: 'push' | 'replace' | 'navigate' = 'push',
|
navigationAction: 'push' | 'replace' | 'navigate' = 'push',
|
||||||
openLink: (href: string) => void,
|
openLink: (href: string) => void,
|
||||||
|
groupChatJoinIntent: (code: string, uri?: string) => void,
|
||||||
e?: Event,
|
e?: Event,
|
||||||
) {
|
) {
|
||||||
let shouldHandle = false
|
let shouldHandle = false
|
||||||
@@ -400,6 +416,11 @@ function onPressInner(
|
|||||||
|
|
||||||
if (shouldHandle) {
|
if (shouldHandle) {
|
||||||
href = convertBskyAppUrlIfNeeded(href)
|
href = convertBskyAppUrlIfNeeded(href)
|
||||||
|
const chatInviteCode = getChatInviteCodeFromUrl(href)
|
||||||
|
if (chatInviteCode) {
|
||||||
|
groupChatJoinIntent(chatInviteCode, href)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
newTab ||
|
newTab ||
|
||||||
href.startsWith('http') ||
|
href.startsWith('http') ||
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ function ListImpl<ItemT>(
|
|||||||
style,
|
style,
|
||||||
disableFullWindowScroll && {
|
disableFullWindowScroll && {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: 'scroll',
|
overflowY: isWithinSplitView ? 'auto' : 'scroll',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
ref={nativeRef as unknown as React.RefObject<View>}>
|
ref={nativeRef as unknown as React.RefObject<View>}>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
type PressableProps,
|
type PressableProps,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
|
type View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {type View} from 'react-native'
|
|
||||||
|
|
||||||
import {addStyle} from '#/lib/styles'
|
import {addStyle} from '#/lib/styles'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user