Merge remote-tracking branch 'origin/main' into fix-lightbox-alt-text-selection
* origin/main: (21 commits) 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) ci: add dependabot config with 7-day cooldown (#10475) Remove image upload flags (#10687) [ALG-96] Don't fall back to search for onboarding user suggestions (#10680) Run GitHub actions in parallel (#10509) Use expo/expo-github-action v9 (#10689) Fix scrollbar behaviour in splitview (#10685) ... # Conflicts: # src/components/Lightbox/chrome/Footer.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
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Testflight Build
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Production APK Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -386,7 +386,7 @@ jobs:
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
uses: slackapi/slack-github-action@v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
+72
-11
@@ -6,33 +6,42 @@ on:
|
||||
branches:
|
||||
- main
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
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:
|
||||
linting:
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job: [lint, prettier]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Verify Node version pins match .nvmrc
|
||||
- name: Verify Node version pins match package.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected=$(tr -d '[:space:]' < .nvmrc)
|
||||
expected=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g, '')")
|
||||
rc=0
|
||||
check() {
|
||||
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
|
||||
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
|
||||
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"
|
||||
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
|
||||
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"
|
||||
@@ -53,10 +62,42 @@ jobs:
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
- name: Prettier check
|
||||
run: pnpm prettier --check .
|
||||
- name: Lint checks
|
||||
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"
|
||||
typechecking:
|
||||
name: Run typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Check & compile i18n
|
||||
run: pnpm intl:build
|
||||
- name: Type check
|
||||
@@ -64,6 +105,10 @@ jobs:
|
||||
testing:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
@@ -83,4 +128,20 @@ jobs:
|
||||
run: pnpm intl:build
|
||||
- name: Run tests
|
||||
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);
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
uses: expo/expo-github-action@v9
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
RUNTIME_VERSION:
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
if: failure()
|
||||
with:
|
||||
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"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
with:
|
||||
header: bundle-diff
|
||||
message: |
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
profile: pull-request
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Delete comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||
|
||||
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/:conversation", server.WebGeneric)
|
||||
e.GET("/messages/:conversation/settings", server.WebGeneric)
|
||||
e.GET("/messages/:conversation/requests", server.WebGeneric)
|
||||
|
||||
// profile endpoints; only first populates info
|
||||
e.GET("/profile/:handleOrDID", server.WebProfile)
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
},
|
||||
"src/components/Post/Embed/ImageEmbed.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": {
|
||||
@@ -169,7 +169,7 @@
|
||||
},
|
||||
"src/components/images/AutoSizedImage.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/images/Gallery/index.tsx": {
|
||||
|
||||
+19
-14
@@ -41,11 +41,14 @@ class BottomSheetView(
|
||||
private val screenHeight: Float =
|
||||
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
|
||||
context.resources.displayMetrics.heightPixels.toFloat()
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
wm.currentWindowMetrics.bounds.height().toFloat()
|
||||
wm.currentWindowMetrics.bounds
|
||||
.height()
|
||||
.toFloat()
|
||||
} else {
|
||||
// API < 30: currentWindowMetrics not available, use getRealSize
|
||||
// which includes system bars (heightPixels may exclude them)
|
||||
@@ -166,6 +169,7 @@ class BottomSheetView(
|
||||
when {
|
||||
// Full height sheets
|
||||
contentHeight >= screenHeight -> 0.99f
|
||||
|
||||
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
|
||||
}
|
||||
|
||||
@@ -271,8 +275,9 @@ class BottomSheetView(
|
||||
}
|
||||
// Apply deferred layout update after gesture completes
|
||||
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
|
||||
newState != BottomSheetBehavior.STATE_SETTLING &&
|
||||
pendingLayoutUpdate) {
|
||||
newState != BottomSheetBehavior.STATE_SETTLING &&
|
||||
pendingLayoutUpdate
|
||||
) {
|
||||
pendingLayoutUpdate = false
|
||||
updateLayout()
|
||||
}
|
||||
@@ -292,7 +297,6 @@ class BottomSheetView(
|
||||
if (!fullHeight) {
|
||||
this.startObservingContentHeight()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun updateLayout() {
|
||||
@@ -365,17 +369,18 @@ class BottomSheetView(
|
||||
|
||||
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
||||
|
||||
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||
val newHeight = bottom - top
|
||||
val oldHeight = oldBottom - oldTop
|
||||
if (newHeight != oldHeight) {
|
||||
val contentHeight = getContentHeight()
|
||||
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||
lastObservedContentHeight = contentHeight
|
||||
updateLayout()
|
||||
val listener =
|
||||
OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||
val newHeight = bottom - top
|
||||
val oldHeight = oldBottom - oldTop
|
||||
if (newHeight != oldHeight) {
|
||||
val contentHeight = getContentHeight()
|
||||
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||
lastObservedContentHeight = contentHeight
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val children = mutableListOf<View>()
|
||||
for (i in 0 until innerViewGroup.childCount) {
|
||||
|
||||
+3
-1
@@ -89,7 +89,8 @@
|
||||
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
|
||||
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=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": {
|
||||
"@atproto/api": "0.20.8",
|
||||
@@ -101,6 +102,7 @@
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
"@bsky.app/peek-menu": "^0.2.4",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@bsky.app/sift": "^0.3.8",
|
||||
"@bsky.app/tapper": "^0.5.7",
|
||||
|
||||
Generated
+16
@@ -268,6 +268,9 @@ importers:
|
||||
'@bsky.app/expo-translate-text':
|
||||
specifier: ^0.2.9
|
||||
version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/peek-menu':
|
||||
specifier: ^0.2.4
|
||||
version: 0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/react-native-mmkv':
|
||||
specifier: 2.12.5
|
||||
version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -1649,6 +1652,13 @@ packages:
|
||||
react: '*'
|
||||
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':
|
||||
resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==}
|
||||
peerDependencies:
|
||||
@@ -10461,6 +10471,12 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
'@bsky.app/peek-menu@0.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)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
||||
+6
-6
@@ -47,18 +47,18 @@ import {
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
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 OnboardingProvider} from '#/state/shell/onboarding'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
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 {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||
import {Shell} from '#/view/shell'
|
||||
import {atoms as a, ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
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 LightboxStateProvider} from '#/components/Lightbox/state'
|
||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||
@@ -113,7 +113,7 @@ function InnerApp() {
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {t: l} = useLingui()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
const hasCheckedLanding = useLandingEntry()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -146,7 +146,7 @@ function InnerApp() {
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ContextMenuProvider>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<Splash isReady={isReady && hasCheckedLanding}>
|
||||
<VideoVolumeProvider>
|
||||
<Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
@@ -246,12 +246,12 @@ function App() {
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<StarterPackProvider>
|
||||
<LandingProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</LandingProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
|
||||
+6
-6
@@ -40,17 +40,17 @@ import {
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
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 OnboardingProvider} from '#/state/shell/onboarding'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
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 {Shell} from '#/view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
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 LightboxStateProvider} from '#/components/Lightbox/state'
|
||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||
@@ -92,7 +92,7 @@ function InnerApp() {
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {t: l} = useLingui()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
const hasCheckedLanding = useLandingEntry()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -125,7 +125,7 @@ function InnerApp() {
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ContextMenuProvider>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<Splash isReady={isReady && hasCheckedLanding}>
|
||||
<VideoVolumeProvider>
|
||||
<ActiveVideoProvider>
|
||||
<Fragment
|
||||
@@ -224,9 +224,9 @@ function App() {
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<LandingProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</LandingProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type State,
|
||||
} from '#/lib/routes/types'
|
||||
import {bskyTitle} from '#/lib/strings/headings'
|
||||
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
@@ -81,6 +82,7 @@ import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
|
||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||
import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
|
||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
||||
@@ -487,6 +489,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
getComponent={() => MessagesConversationSettingsScreen}
|
||||
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
|
||||
name="MessagesSettings"
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
@@ -797,6 +804,19 @@ const LINKING = {
|
||||
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 (name === 'Search') {
|
||||
return buildStateObject('SearchTab', 'Search', params)
|
||||
|
||||
@@ -9,14 +9,11 @@ export enum Features {
|
||||
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
ImageUploadsHighResolution = 'image_uploads:high_resolution',
|
||||
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
|
||||
GroupChatsEnable = 'group_chats:enable',
|
||||
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
LargeVideoUploads = 'large_video_uploads:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -28,23 +28,29 @@ type Layout = {
|
||||
border?: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
animate?: boolean
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
size?: number
|
||||
moderationOpts?: ModerationOpts
|
||||
}
|
||||
|
||||
export function AvatarBubbles({
|
||||
animate = false,
|
||||
profiles: allProfiles,
|
||||
self = false,
|
||||
size = 120,
|
||||
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 profiles =
|
||||
allProfiles.length > 2
|
||||
? allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||
!self && allProfiles.length > 2
|
||||
? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
|
||||
: allProfiles
|
||||
const moderations = useMemo(() => {
|
||||
if (!moderationOpts) return []
|
||||
|
||||
@@ -757,11 +757,11 @@ export function useSharedButtonTextStyles() {
|
||||
}
|
||||
|
||||
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') {
|
||||
baseStyles.push(a.text_sm, a.leading_snug, a.font_medium)
|
||||
baseStyles.push(a.text_sm, a.font_medium)
|
||||
} 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)
|
||||
|
||||
@@ -230,7 +230,11 @@ function LightboxGallery({
|
||||
style={[
|
||||
a.px_4xl,
|
||||
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,
|
||||
]}>
|
||||
<Pressable
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useRef} from 'react'
|
||||
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {BlurView} from 'expo-blur'
|
||||
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'
|
||||
|
||||
type Props = {
|
||||
@@ -31,51 +32,65 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
{paddingBottom: insets.bottom + 8},
|
||||
]}>
|
||||
<View style={[a.mx_md, styles.altWrap]}>
|
||||
<ScrollView
|
||||
scrollEnabled={isAltExpanded}
|
||||
onMomentumScrollBegin={() => {
|
||||
isMomentumScrolling.current = true
|
||||
}}
|
||||
onMomentumScrollEnd={() => {
|
||||
isMomentumScrolling.current = false
|
||||
}}
|
||||
contentContainerStyle={[a.px_md, a.py_sm]}>
|
||||
<View
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Expand alt text`}
|
||||
accessibilityHint="">
|
||||
{/*
|
||||
* The press handlers must live on the Text itself, not on a
|
||||
* wrapping Pressable. Text selection is driven by the platform's
|
||||
* 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
|
||||
intensity={16}
|
||||
tint="dark"
|
||||
style={[
|
||||
// Tint kept over the blur so dense, text-heavy images stay
|
||||
// readable. On Android the blur falls back to a flat overlay, so
|
||||
// bump the opacity to keep the contrast the real blur provides
|
||||
// elsewhere.
|
||||
platform({
|
||||
ios: {backgroundColor: 'rgba(0, 0, 0, 0.5)'},
|
||||
android: {backgroundColor: 'rgba(0, 0, 0, 0.7)'},
|
||||
}),
|
||||
]}>
|
||||
<ScrollView
|
||||
scrollEnabled={isAltExpanded}
|
||||
onMomentumScrollBegin={() => {
|
||||
isMomentumScrolling.current = true
|
||||
}}
|
||||
onMomentumScrollEnd={() => {
|
||||
isMomentumScrolling.current = false
|
||||
}}
|
||||
contentContainerStyle={[a.px_md, a.py_sm]}>
|
||||
<View
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Expand alt text`}
|
||||
accessibilityHint="">
|
||||
{/*
|
||||
* The press handlers must live on the Text itself, not on a
|
||||
* wrapping Pressable. Text selection is driven by the platform's
|
||||
* 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>
|
||||
)
|
||||
@@ -83,7 +98,6 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
altWrap: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@react-navigation/native'
|
||||
|
||||
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
||||
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
|
||||
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
|
||||
@@ -19,6 +20,7 @@ import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
createProxiedUrl,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyDownloadUrl,
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
@@ -130,6 +132,7 @@ export function useLink({
|
||||
const {closeModal} = useModalControls()
|
||||
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
||||
const openLink = useOpenLink()
|
||||
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||
|
||||
const onPress = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
@@ -148,6 +151,12 @@ export function useLink({
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const chatInviteCode = getChatInviteCodeFromUrl(href)
|
||||
if (chatInviteCode) {
|
||||
groupChatJoinIntent(chatInviteCode, href)
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresWarning) {
|
||||
linkWarningDialogControl.open({
|
||||
displayText,
|
||||
@@ -228,6 +237,7 @@ export function useLink({
|
||||
overridePresentation,
|
||||
shouldProxy,
|
||||
linkWarningDialogControl,
|
||||
groupChatJoinIntent,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
|
||||
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 {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 * as PeekMenu from '#/components/PeekMenu'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import * as bsky from '#/types/bsky'
|
||||
@@ -16,9 +21,11 @@ import * as bsky from '#/types/bsky'
|
||||
export function Embed({
|
||||
embed,
|
||||
style,
|
||||
peekable = false,
|
||||
}: {
|
||||
embed: AppBskyFeedDefs.PostView['embed']
|
||||
style?: StyleProp<ViewStyle>
|
||||
peekable?: boolean
|
||||
}) {
|
||||
const e = bsky.post.parseEmbed(embed)
|
||||
|
||||
@@ -27,13 +34,17 @@ export function Embed({
|
||||
if (e.type === 'images') {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
{e.view.images.map(image => (
|
||||
<ImageItem
|
||||
key={image.thumb}
|
||||
thumbnail={image.thumb}
|
||||
alt={image.alt}
|
||||
/>
|
||||
))}
|
||||
{e.view.images.map(image =>
|
||||
peekable ? (
|
||||
<PeekableImageItem key={image.thumb} image={image} />
|
||||
) : (
|
||||
<ImageItem
|
||||
key={image.thumb}
|
||||
thumbnail={image.thumb}
|
||||
alt={image.alt}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Outer>
|
||||
)
|
||||
} 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({
|
||||
altContainer: {
|
||||
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 {type AnimatedRef} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
@@ -8,6 +9,7 @@ import {Gallery} from '#/components/images/Gallery'
|
||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||
import {useLightboxControls} from '#/components/Lightbox/state'
|
||||
import {type Dimensions} from '#/components/Lightbox/types'
|
||||
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {type EmbedType} from '#/types/bsky/post'
|
||||
@@ -24,6 +26,11 @@ export function ImageEmbed({
|
||||
const {images} = embed.view
|
||||
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||
|
||||
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
|
||||
// ref + dims that a tap would — keeps the lightbox's return animation intact.
|
||||
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
|
||||
const singleDimsRef = useRef<Dimensions | null>(null)
|
||||
|
||||
if (images.length > 0) {
|
||||
const items = images.map(img => ({
|
||||
uri: img.fullsize,
|
||||
@@ -59,24 +66,49 @@ export function ImageEmbed({
|
||||
|
||||
if (images.length === 1) {
|
||||
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 (
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<AutoSizedImage
|
||||
crop={
|
||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: rest.viewContext ===
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
|
||||
onPressIn={() => onPressIn(0)}
|
||||
hideBadge={
|
||||
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
/>
|
||||
<ImageContextMenu
|
||||
fullsizeUri={image.fullsize}
|
||||
thumbUri={image.thumb}
|
||||
aspectRatio={aspect}
|
||||
borderRadius={tokens.borderRadius.md}
|
||||
onPreviewPress={openFromSingle}>
|
||||
<AutoSizedImage
|
||||
crop={
|
||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: rest.viewContext ===
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? '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.viewContext ===
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
/>
|
||||
</ImageContextMenu>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function RichText({
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const plainStyles = [a.leading_snug, style]
|
||||
const plainStyles = style
|
||||
const interactiveStyles = [plainStyles, interactiveStyle]
|
||||
|
||||
const {text, facets} = richText
|
||||
|
||||
@@ -31,6 +31,7 @@ export function Text({
|
||||
[
|
||||
a.text_sm,
|
||||
t.atoms.text,
|
||||
a.leading_snug,
|
||||
web(numberOfLines === 1 && numberOfLinesClippingFix),
|
||||
style,
|
||||
],
|
||||
|
||||
@@ -219,7 +219,7 @@ export function AddMembersFlow({
|
||||
}
|
||||
}
|
||||
|
||||
if (searchText === '') {
|
||||
if (searchText === '' && _items.length > 0) {
|
||||
_items.unshift({
|
||||
type: 'label',
|
||||
key: 'suggested',
|
||||
|
||||
@@ -316,6 +316,7 @@ export function InitiateChatFlow({
|
||||
|
||||
if (
|
||||
searchText === '' &&
|
||||
_items.length > 0 &&
|
||||
(chatState === ChatState.NEW_CHAT ||
|
||||
chatState === ChatState.NEW_GROUP_CHAT)
|
||||
) {
|
||||
|
||||
+28
-4
@@ -1,22 +1,45 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import * as Linking from 'expo-linking'
|
||||
|
||||
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
||||
import {
|
||||
createStarterPackLinkFromAndroidReferrer,
|
||||
httpStarterPackUriToAtUri,
|
||||
} from '#/lib/strings/starter-pack'
|
||||
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
|
||||
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 {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
export function useStarterPackEntry() {
|
||||
export function useLandingEntry() {
|
||||
const [ready, setReady] = useState(false)
|
||||
const setActiveStarterPack = useSetActiveStarterPack()
|
||||
const setActiveLanding = useSetActiveLanding()
|
||||
const hasCheckedForStarterPack = useHasCheckedForStarterPack()
|
||||
|
||||
useEffect(() => {
|
||||
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,
|
||||
// let's just ensure we never check again after the first time.
|
||||
if (hasCheckedForStarterPack) {
|
||||
@@ -29,7 +52,8 @@ export function useStarterPackEntry() {
|
||||
setReady(true)
|
||||
}, 500)
|
||||
|
||||
;(async () => {
|
||||
void (async () => {
|
||||
// Check for starter pack
|
||||
let uri: string | null | undefined
|
||||
|
||||
if (IS_ANDROID) {
|
||||
@@ -58,7 +82,7 @@ export function useStarterPackEntry() {
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [ready, setActiveStarterPack, hasCheckedForStarterPack])
|
||||
}, [ready, setActiveStarterPack, setActiveLanding, hasCheckedForStarterPack])
|
||||
|
||||
return ready
|
||||
}
|
||||
+7
-5
@@ -1,25 +1,27 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
|
||||
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 setActiveStarterPack = useSetActiveStarterPack()
|
||||
|
||||
useEffect(() => {
|
||||
const href = window.location.href
|
||||
const atUri = httpStarterPackUriToAtUri(href)
|
||||
const url = new URL(href)
|
||||
|
||||
// Check for starter pack
|
||||
const atUri = httpStarterPackUriToAtUri(href)
|
||||
if (atUri) {
|
||||
const url = new URL(href)
|
||||
// Determines if an App Clip is loading this landing page
|
||||
const isClip = url.searchParams.get('clip') === 'true'
|
||||
setActiveStarterPack({
|
||||
uri: atUri,
|
||||
isClip,
|
||||
})
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
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 {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(
|
||||
function LogoImpl(props: Props, ref) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
@@ -41,7 +53,7 @@ export function createSinglePathSVG({
|
||||
strokeLinecap?: 'butt' | 'round' | 'square'
|
||||
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 hasStroke = strokeWidth > 0
|
||||
@@ -68,7 +80,11 @@ export function createSinglePathSVG({
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
}) as IconWithSvgMeta
|
||||
Icon.svgPaths = [path]
|
||||
Icon.svgViewBox = viewBox || '0 0 24 24'
|
||||
Icon.svgStrokeWidth = strokeWidth
|
||||
return Icon
|
||||
}
|
||||
|
||||
export function createMultiPathSVG({
|
||||
@@ -78,7 +94,7 @@ export function createMultiPathSVG({
|
||||
paths: 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)
|
||||
|
||||
return (
|
||||
@@ -102,5 +118,9 @@ export function createMultiPathSVG({
|
||||
))}
|
||||
</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 Animated, {
|
||||
type AnimatedRef,
|
||||
@@ -69,6 +69,8 @@ export function AutoSizedImage({
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
onContainerRef,
|
||||
onDimsChange,
|
||||
}: {
|
||||
image: AppBskyEmbedImages.ViewImage
|
||||
crop?: 'none' | 'square' | 'constrained'
|
||||
@@ -79,6 +81,11 @@ export function AutoSizedImage({
|
||||
) => void
|
||||
onLongPress?: () => 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 {_} = useLingui()
|
||||
@@ -86,6 +93,10 @@ export function AutoSizedImage({
|
||||
const containerRef = useAnimatedRef()
|
||||
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
onContainerRef?.(containerRef)
|
||||
}, [containerRef, onContainerRef])
|
||||
|
||||
let aspectRatio: number | undefined
|
||||
const dims = image.aspectRatio
|
||||
if (dims) {
|
||||
@@ -122,10 +133,12 @@ export function AutoSizedImage({
|
||||
accessibilityHint=""
|
||||
onLoad={e => {
|
||||
if (!isContain) {
|
||||
fetchedDimsRef.current = {
|
||||
const dims = {
|
||||
width: e.source.width,
|
||||
height: e.source.height,
|
||||
}
|
||||
fetchedDimsRef.current = dims
|
||||
onDimsChange?.(dims)
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
|
||||
@@ -24,7 +24,7 @@ import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
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 {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
import {
|
||||
@@ -36,6 +36,7 @@ import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandle
|
||||
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
|
||||
import {getAspectRatio} from '#/components/images/Gallery/utils'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
@@ -264,6 +265,21 @@ export function Gallery({
|
||||
data={images}
|
||||
keyExtractor={(item, index) => item.thumb + 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 (
|
||||
<GalleryImage
|
||||
hideBadges={hideBadges}
|
||||
@@ -288,24 +304,9 @@ export function Gallery({
|
||||
onThumbDims={(i, dims) => {
|
||||
thumbDimsRef.current.set(i, dims)
|
||||
}}
|
||||
onPress={
|
||||
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
|
||||
}
|
||||
onPress={openLightboxAtIndex}
|
||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||
onPreviewPress={openLightboxAtIndex}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
@@ -378,6 +379,7 @@ function GalleryImage({
|
||||
onThumbDims,
|
||||
onPress,
|
||||
onPressIn,
|
||||
onPreviewPress,
|
||||
}: {
|
||||
contentHeight: number
|
||||
image: AppBskyEmbedImages.ViewImage
|
||||
@@ -391,6 +393,7 @@ function GalleryImage({
|
||||
onThumbDims: (index: number, dims: Dimensions) => void
|
||||
onPress?: () => void
|
||||
onPressIn?: () => void
|
||||
onPreviewPress?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
@@ -416,124 +419,131 @@ function GalleryImage({
|
||||
collapsable={false}
|
||||
aria-roledescription={l`slide`}
|
||||
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
|
||||
<Pressable
|
||||
ref={itemRef}
|
||||
tabIndex={index === 0 ? 0 : -1}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={image.alt || l`Image ${index + 1}`}
|
||||
accessibilityHint={l`Opens full image`}
|
||||
android_ripple={{
|
||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||
foreground: true,
|
||||
}}
|
||||
style={({pressed}) => [
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
web([
|
||||
{
|
||||
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,
|
||||
})
|
||||
<ImageContextMenu
|
||||
fullsizeUri={image.fullsize}
|
||||
thumbUri={image.thumb}
|
||||
aspectRatio={aspectRatio}
|
||||
borderRadius={tokens.borderRadius.md}
|
||||
onPreviewPress={onPreviewPress}>
|
||||
<Pressable
|
||||
ref={itemRef}
|
||||
tabIndex={index === 0 ? 0 : -1}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={image.alt || l`Image ${index + 1}`}
|
||||
accessibilityHint={l`Opens full image`}
|
||||
android_ripple={{
|
||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||
foreground: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
{(hasAlt || isCropped) && !hideBadges ? (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
style={({pressed}) => [
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
web([
|
||||
{
|
||||
bottom: a.p_xs.padding,
|
||||
right: a.p_xs.padding,
|
||||
gap: 3,
|
||||
cursor: 'inherit',
|
||||
outline: 0,
|
||||
border: 0,
|
||||
},
|
||||
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}
|
||||
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,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<MediaInsetBorder
|
||||
style={
|
||||
focused && {
|
||||
borderWidth: 2,
|
||||
{(hasAlt || isCropped) && !hideBadges ? (
|
||||
<View
|
||||
accessible={false}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
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 {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -52,46 +53,63 @@ export function GalleryItem({
|
||||
const hasAlt = !!image.alt
|
||||
const hideBadges =
|
||||
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 (
|
||||
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
|
||||
<Pressable
|
||||
onPress={
|
||||
onPress
|
||||
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
|
||||
: undefined
|
||||
}
|
||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
|
||||
android_ripple={{
|
||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||
foreground: true,
|
||||
}}
|
||||
style={[
|
||||
a.flex_1,
|
||||
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,
|
||||
}
|
||||
<ImageContextMenu
|
||||
fullsizeUri={image.fullsize}
|
||||
thumbUri={image.thumb}
|
||||
aspectRatio={aspect}
|
||||
borderRadius={tokens.borderRadius.md}
|
||||
onPreviewPress={openLightboxAtIndex}
|
||||
style={a.flex_1}>
|
||||
<Pressable
|
||||
onPress={openLightboxAtIndex}
|
||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
|
||||
android_ripple={{
|
||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||
foreground: true,
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
<MediaInsetBorder style={insetBorderStyle} />
|
||||
</Pressable>
|
||||
style={[
|
||||
a.flex_1,
|
||||
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"
|
||||
/>
|
||||
<MediaInsetBorder style={insetBorderStyle} />
|
||||
</Pressable>
|
||||
</ImageContextMenu>
|
||||
{hasAlt && !hideBadges ? (
|
||||
<View
|
||||
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 {type DialogControlProps} from '#/components/Dialog'
|
||||
import {GroupChatJoinDialog} from '#/components/intents/GroupChatJoinDialog'
|
||||
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
|
||||
|
||||
interface Context {
|
||||
verifyEmailDialogControl: DialogControlProps
|
||||
verifyEmailState: {code: string} | undefined
|
||||
setVerifyEmailState: (state: {code: string} | undefined) => void
|
||||
groupChatJoinDialogControl: DialogControlProps
|
||||
groupChatJoinState: {code: string} | undefined
|
||||
setGroupChatJoinState: (state: {code: string} | undefined) => void
|
||||
}
|
||||
|
||||
const Context = createContext({} as Context)
|
||||
@@ -19,20 +36,70 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [verifyEmailState, setVerifyEmailState] = useState<
|
||||
{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(
|
||||
() => ({
|
||||
verifyEmailDialogControl,
|
||||
verifyEmailState,
|
||||
setVerifyEmailState,
|
||||
groupChatJoinDialogControl,
|
||||
groupChatJoinState,
|
||||
setGroupChatJoinState,
|
||||
}),
|
||||
[verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
|
||||
[
|
||||
verifyEmailDialogControl,
|
||||
verifyEmailState,
|
||||
setVerifyEmailState,
|
||||
groupChatJoinDialogControl,
|
||||
groupChatJoinState,
|
||||
setGroupChatJoinState,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<Context.Provider value={value}>
|
||||
{children}
|
||||
<VerifyEmailIntentDialog />
|
||||
<GroupChatJoinDialog />
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-21
@@ -51,16 +51,10 @@ interface PostOpts {
|
||||
langs?: string[]
|
||||
}
|
||||
|
||||
type FeatureFlags = {
|
||||
highResolutionImages?: boolean
|
||||
increasedBlobSizeLimit?: boolean
|
||||
}
|
||||
|
||||
export async function post(
|
||||
agent: BskyAgent,
|
||||
queryClient: QueryClient,
|
||||
opts: PostOpts,
|
||||
featureFlags?: FeatureFlags,
|
||||
) {
|
||||
const thread = opts.thread
|
||||
opts.onStateChange?.(t`Processing...`)
|
||||
@@ -97,7 +91,6 @@ export async function post(
|
||||
queryClient,
|
||||
draft,
|
||||
opts.onStateChange,
|
||||
featureFlags,
|
||||
)
|
||||
let labels: $Typed<ComAtprotoLabelDefs.SelfLabels> | undefined
|
||||
if (draft.labels.length) {
|
||||
@@ -257,7 +250,6 @@ async function resolveEmbed(
|
||||
queryClient: QueryClient,
|
||||
draft: PostDraft,
|
||||
onStateChange: ((state: string) => void) | undefined,
|
||||
featureFlags?: FeatureFlags,
|
||||
): Promise<
|
||||
| $Typed<AppBskyEmbedImages.Main>
|
||||
| $Typed<AppBskyEmbedVideo.Main>
|
||||
@@ -268,13 +260,7 @@ async function resolveEmbed(
|
||||
> {
|
||||
if (draft.embed.quote) {
|
||||
const [resolvedMedia, resolvedQuote] = await Promise.all([
|
||||
resolveMedia(
|
||||
agent,
|
||||
queryClient,
|
||||
draft.embed,
|
||||
onStateChange,
|
||||
featureFlags,
|
||||
),
|
||||
resolveMedia(agent, queryClient, draft.embed, onStateChange),
|
||||
resolveRecord(agent, queryClient, draft.embed.quote.uri),
|
||||
])
|
||||
if (resolvedMedia) {
|
||||
@@ -297,7 +283,6 @@ async function resolveEmbed(
|
||||
queryClient,
|
||||
draft.embed,
|
||||
onStateChange,
|
||||
featureFlags,
|
||||
)
|
||||
if (resolvedMedia) {
|
||||
return resolvedMedia
|
||||
@@ -323,7 +308,6 @@ async function resolveMedia(
|
||||
queryClient: QueryClient,
|
||||
embedDraft: EmbedDraft,
|
||||
onStateChange: ((state: string) => void) | undefined,
|
||||
featureFlags?: FeatureFlags,
|
||||
): Promise<
|
||||
| $Typed<AppBskyEmbedExternal.Main>
|
||||
| $Typed<AppBskyEmbedImages.Main>
|
||||
@@ -339,10 +323,7 @@ async function resolveMedia(
|
||||
const images: AppBskyEmbedImages.Image[] = await Promise.all(
|
||||
imagesDraft.map(async (image, i) => {
|
||||
logger.debug(`Compressing image #${i}`)
|
||||
const {path, width, height, mime} = await compressImage(image, {
|
||||
highResolution: featureFlags?.highResolutionImages,
|
||||
increasedBlobSizeLimit: featureFlags?.increasedBlobSizeLimit,
|
||||
})
|
||||
const {path, width, height, mime} = await compressImage(image)
|
||||
logger.debug(`Uploading image #${i}`)
|
||||
const res = await uploadBlob(agent, path, mime)
|
||||
return {
|
||||
|
||||
@@ -246,6 +246,10 @@ async function resolveExternal(
|
||||
title: result.title ?? '',
|
||||
description: result.description ?? '',
|
||||
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,
|
||||
view: result.view,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
|
||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
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_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
|
||||
@@ -188,8 +189,8 @@ export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
|
||||
* Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
|
||||
* ISO megabytes.
|
||||
*/
|
||||
export const VIDEO_MAX_SIZE_REDUCED = 1000 * 1000 * 100 // 100mb
|
||||
export const VIDEO_MAX_SIZE = 3000 * 1000 * 100 // 300mb
|
||||
export const VIDEO_MAX_SIZE_MB = 300
|
||||
export const VIDEO_MAX_SIZE = VIDEO_MAX_SIZE_MB * 1000 * 1000 // 300mb
|
||||
|
||||
export const SUPPORTED_MIME_TYPES = [
|
||||
'video/mp4',
|
||||
|
||||
@@ -5,7 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
|
||||
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
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 {useSetActiveLanding} from '#/state/shell/landing'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
@@ -25,6 +29,7 @@ export function useIntentHandler() {
|
||||
const ax = useAnalytics()
|
||||
const composeIntent = useComposeIntent()
|
||||
const verifyEmailIntent = useVerifyEmailIntent()
|
||||
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||
const {currentAccount} = useSession()
|
||||
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
|
||||
|
||||
@@ -44,6 +49,11 @@ export function useIntentHandler() {
|
||||
})
|
||||
}
|
||||
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('/')
|
||||
|
||||
// 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,
|
||||
composeIntent,
|
||||
verifyEmailIntent,
|
||||
groupChatJoinIntent,
|
||||
currentAccount,
|
||||
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() {
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
|
||||
|
||||
@@ -12,7 +12,6 @@ export async function compressVideo(
|
||||
opts?: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
TEMP_enableLargeVideoUploads?: boolean
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, signal} = opts || {}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {VIDEO_MAX_SIZE, VIDEO_MAX_SIZE_REDUCED} from '#/lib/constants'
|
||||
import {VIDEO_MAX_SIZE} from '#/lib/constants'
|
||||
import {VideoTooLargeError} from '#/lib/media/video/errors'
|
||||
import {type CompressedVideo} from './types'
|
||||
|
||||
// doesn't actually compress, converts to ArrayBuffer
|
||||
export async function compressVideo(
|
||||
asset: ImagePickerAsset,
|
||||
opts?: {
|
||||
_opts?: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
TEMP_enableLargeVideoUploads?: number
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {mimeType, base64} = parseDataUrl(asset.uri)
|
||||
const blob = base64ToBlob(base64, mimeType)
|
||||
const uri = URL.createObjectURL(blob)
|
||||
|
||||
if (
|
||||
blob.size >
|
||||
(opts?.TEMP_enableLargeVideoUploads
|
||||
? VIDEO_MAX_SIZE
|
||||
: VIDEO_MAX_SIZE_REDUCED)
|
||||
) {
|
||||
if (blob.size > VIDEO_MAX_SIZE) {
|
||||
throw new VideoTooLargeError()
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export type CommonNavigatorParams = {
|
||||
Topic: {topic: string}
|
||||
MessagesConversation: {conversation: string; embed?: string; accept?: true}
|
||||
MessagesConversationSettings: {conversation: string}
|
||||
MessagesJoinRequests: {conversation: string}
|
||||
MessagesSettings: undefined
|
||||
MessagesInbox: undefined
|
||||
NotificationsActivityList: {posts: string}
|
||||
|
||||
@@ -178,6 +178,28 @@ export function isBskyStarterPackUrl(url: string): boolean {
|
||||
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 {
|
||||
if (isExternalUrl(url)) {
|
||||
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',
|
||||
MessagesConversation: '/messages/:conversation',
|
||||
MessagesConversationSettings: '/messages/:conversation/settings',
|
||||
MessagesJoinRequests: '/messages/:conversation/requests',
|
||||
// starter packs
|
||||
Start: '/start/:name/:rkey',
|
||||
StarterPackEdit: '/starter-pack/edit/:rkey',
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import {View} from 'react-native'
|
||||
import {plural} from '@lingui/core/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 {InlineLinkText} from '#/components/Link'
|
||||
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MembersAndRequests({
|
||||
memberCount,
|
||||
memberLimit,
|
||||
convo,
|
||||
requestCount,
|
||||
hasMoreRequests,
|
||||
isOwner,
|
||||
}: {
|
||||
memberCount: number
|
||||
memberLimit: number
|
||||
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||
requestCount: number
|
||||
hasMoreRequests: boolean
|
||||
isOwner: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const memberCount = convo.details.memberCount
|
||||
const memberLimit = convo.details.memberLimit
|
||||
|
||||
return (
|
||||
<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
|
||||
label={l`View incoming group chat requests`}
|
||||
style={[a.text_sm, a.text_right, a.font_semi_bold]}
|
||||
// TODO Need to implement this. -dsb
|
||||
to="#">
|
||||
{...createStaticClick(() => {
|
||||
navigation.navigate('MessagesJoinRequests', {
|
||||
conversation: convo.view.id,
|
||||
})
|
||||
})}>
|
||||
{hasMoreRequests
|
||||
? l({
|
||||
message: `${requestCount}+ requests`,
|
||||
|
||||
@@ -247,8 +247,7 @@ function GroupSettings({
|
||||
case 'MEMBERS_AND_REQUESTS':
|
||||
return (
|
||||
<MembersAndRequests
|
||||
memberCount={convo.details.memberCount}
|
||||
memberLimit={convo.details.memberLimit}
|
||||
convo={convo}
|
||||
requestCount={requestCount}
|
||||
hasMoreRequests={!!hasMoreRequests}
|
||||
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 linkDisabled = joinLink?.enabledStatus === 'disabled'
|
||||
const joinLinkURI = joinLink?.code
|
||||
? `https://bsky.app/chat/${joinLink.code}`
|
||||
: 'https://bsky.app/chat'
|
||||
? `https://bsky.app/c/${joinLink.code}`
|
||||
: 'https://bsky.app/'
|
||||
const createdAt = joinLink ? new Date(joinLink.createdAt) : null
|
||||
const currentOption =
|
||||
whoCanJoinOptions.find(
|
||||
|
||||
@@ -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 isJoinLinkEnabled =
|
||||
isOwner || convo.details.joinLink?.enabledStatus === 'enabled'
|
||||
|
||||
const members = (convo.members ?? []).filter(
|
||||
profile => profile.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
@@ -28,11 +28,11 @@ import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||
import {RQKEY as profileRQKey} from '#/state/queries/profile'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {useProgressGuideControls} from '#/state/shell/progress-guide'
|
||||
import {
|
||||
useActiveStarterPack,
|
||||
useSetActiveStarterPack,
|
||||
} from '#/state/shell/starter-pack'
|
||||
} from '#/state/shell/landing'
|
||||
import {useProgressGuideControls} from '#/state/shell/progress-guide'
|
||||
import {
|
||||
OnboardingControls,
|
||||
OnboardingHeaderSlot,
|
||||
|
||||
@@ -123,7 +123,11 @@ export function AutomationLabelSettingsScreen({}: Props) {
|
||||
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 style={[a.flex_row, a.align_baseline]}>
|
||||
<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 {useServiceQuery} from '#/state/queries/service'
|
||||
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 {
|
||||
initialState,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {useStarterPackQuery} from '#/state/queries/starter-packs'
|
||||
import {
|
||||
useActiveStarterPack,
|
||||
useSetActiveStarterPack,
|
||||
} from '#/state/shell/starter-pack'
|
||||
} from '#/state/shell/landing'
|
||||
import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
|
||||
@@ -37,12 +37,12 @@ import {
|
||||
useStarterPackQuery,
|
||||
} from '#/state/queries/starter-packs'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useSetActiveStarterPack} from '#/state/shell/landing'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {
|
||||
ProgressGuideAction,
|
||||
useProgressGuideControls,
|
||||
} from '#/state/shell/progress-guide'
|
||||
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
|
||||
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
|
||||
import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
|
||||
import {bulkWriteFollows} from '#/screens/Onboarding/util'
|
||||
|
||||
+3
-11
@@ -13,7 +13,6 @@ import {
|
||||
} from 'expo-image-manipulator'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {getImageDim} from '#/lib/media/manip'
|
||||
import {openCropper} from '#/lib/media/picker'
|
||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||
@@ -202,19 +201,12 @@ export function resetImageManipulation(
|
||||
return img
|
||||
}
|
||||
|
||||
export async function compressImage(
|
||||
img: ComposerImage,
|
||||
options?: {
|
||||
highResolution?: boolean
|
||||
increasedBlobSizeLimit?: boolean
|
||||
},
|
||||
): Promise<PickerImage> {
|
||||
export async function compressImage(img: ComposerImage): Promise<PickerImage> {
|
||||
const source = img.transformed || img.source
|
||||
const highResolution = options?.highResolution ?? false
|
||||
|
||||
let attempts = 0
|
||||
let maxDimension = highResolution ? 4000 : POST_IMG_MAX.width
|
||||
let maxBytes = options?.increasedBlobSizeLimit ? 2000000 : POST_IMG_MAX.size
|
||||
let maxDimension = 4000
|
||||
let maxBytes = 2000000
|
||||
|
||||
let minQualityPercentage = 0
|
||||
let maxQualityPercentage = 101 // exclusive
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||
@@ -43,7 +43,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
...moderationPrefs,
|
||||
labelers: moderationPrefs.labelers.length
|
||||
? moderationPrefs.labelers
|
||||
: BskyAgent.appLabelers.map(did => ({
|
||||
: AtpAgent.appLabelers.map(did => ({
|
||||
did,
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 {useSession} from '#/state/session'
|
||||
import {useActiveStarterPack} from '#/state/shell/starter-pack'
|
||||
import {useActiveLanding} from '#/state/shell/landing'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
type State = {
|
||||
@@ -26,7 +26,12 @@ type Controls = {
|
||||
/**
|
||||
* The did of the account to populate the login form with.
|
||||
*/
|
||||
requestedAccount?: (string & {}) | 'none' | 'new' | 'starterpack'
|
||||
requestedAccount?:
|
||||
| (string & {})
|
||||
| 'none'
|
||||
| 'new'
|
||||
| 'starterpack'
|
||||
| 'groupchat'
|
||||
}) => void
|
||||
/**
|
||||
* Clears the requested account so that next time the logged out view is
|
||||
@@ -48,17 +53,34 @@ const ControlsContext = createContext<Controls>({
|
||||
})
|
||||
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<{}>) {
|
||||
const activeStarterPack = useActiveStarterPack()
|
||||
const activeLanding = useActiveLanding()
|
||||
const {hasSession} = useSession()
|
||||
const shouldShowStarterPack = Boolean(activeStarterPack?.uri) && !hasSession
|
||||
|
||||
const requestedAccount = getRequestedAccountFromLanding(
|
||||
activeLanding,
|
||||
hasSession,
|
||||
)
|
||||
|
||||
const [state, setState] = useState<State>({
|
||||
showLoggedOut: shouldShowStarterPack,
|
||||
requestedAccountSwitchTo: shouldShowStarterPack
|
||||
? IS_WEB
|
||||
? 'starterpack'
|
||||
: 'new'
|
||||
: undefined,
|
||||
showLoggedOut: Boolean(requestedAccount),
|
||||
requestedAccountSwitchTo: requestedAccount,
|
||||
})
|
||||
|
||||
const controls = useMemo<Controls>(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {profilesQueryKey} from '#/state/queries/profile'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useSetActiveLanding} from '#/state/shell/landing'
|
||||
import {
|
||||
useLoggedOutView,
|
||||
useLoggedOutViewControls,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import {useEnableMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {Login} from '#/screens/Login'
|
||||
import {JoinRequest} from '#/screens/Messages/JoinRequest'
|
||||
import {Signup} from '#/screens/Signup'
|
||||
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
|
||||
import {atoms as a, native, tokens, useTheme} from '#/alf'
|
||||
@@ -29,9 +31,26 @@ enum ScreenState {
|
||||
S_Login,
|
||||
S_CreateAccount,
|
||||
S_StarterPack,
|
||||
S_GroupChatJoinRequest,
|
||||
}
|
||||
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}) {
|
||||
const {_} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
@@ -39,18 +58,11 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
useEnableMinimalShellMode()
|
||||
const {requestedAccountSwitchTo} = useLoggedOutView()
|
||||
const [screenState, setScreenState] = useState<ScreenState>(() => {
|
||||
if (requestedAccountSwitchTo === 'new') {
|
||||
return ScreenState.S_CreateAccount
|
||||
} else if (requestedAccountSwitchTo === 'starterpack') {
|
||||
return ScreenState.S_StarterPack
|
||||
} else if (requestedAccountSwitchTo != null) {
|
||||
return ScreenState.S_Login
|
||||
} else {
|
||||
return ScreenState.S_LoginOrCreateAccount
|
||||
}
|
||||
})
|
||||
const initialScreenState = getInitialScreenState(requestedAccountSwitchTo)
|
||||
const [screenState, setScreenState] =
|
||||
useState<ScreenState>(initialScreenState)
|
||||
const {clearRequestedAccount} = useLoggedOutViewControls()
|
||||
const setActiveLanding = useSetActiveLanding()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const {accounts} = useSession()
|
||||
@@ -73,7 +85,9 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
onDismiss()
|
||||
}
|
||||
clearRequestedAccount()
|
||||
}, [clearRequestedAccount, onDismiss])
|
||||
// Clear landing context when user dismisses the modal
|
||||
setActiveLanding(undefined)
|
||||
}, [clearRequestedAccount, onDismiss, setActiveLanding])
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -107,6 +121,8 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
|
||||
{screenState === ScreenState.S_StarterPack ? (
|
||||
<LandingScreen setScreenState={setScreenState} />
|
||||
) : screenState === ScreenState.S_GroupChatJoinRequest ? (
|
||||
<JoinRequest setScreenState={setScreenState} />
|
||||
) : screenState === ScreenState.S_LoginOrCreateAccount ? (
|
||||
<SplashScreen
|
||||
onPressSignin={() => {
|
||||
@@ -122,17 +138,12 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
{screenState === ScreenState.S_Login ? (
|
||||
<Login
|
||||
onPressBack={() => {
|
||||
setScreenState(ScreenState.S_LoginOrCreateAccount)
|
||||
clearRequestedAccount()
|
||||
setScreenState(initialScreenState)
|
||||
}}
|
||||
/>
|
||||
) : undefined}
|
||||
{screenState === ScreenState.S_CreateAccount ? (
|
||||
<Signup
|
||||
onPressBack={() =>
|
||||
setScreenState(ScreenState.S_LoginOrCreateAccount)
|
||||
}
|
||||
/>
|
||||
<Signup onPressBack={() => setScreenState(initialScreenState)} />
|
||||
) : undefined}
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
|
||||
@@ -222,10 +222,6 @@ export const ComposePost = ({
|
||||
const [publishingStage, setPublishingStage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const enableLargeVideoUploads = ax.features.enabled(
|
||||
ax.features.LargeVideoUploads,
|
||||
)
|
||||
|
||||
/**
|
||||
* Track when a draft was created so we can measure draft age in metrics.
|
||||
* Set when a draft is loaded via handleSelectDraft.
|
||||
@@ -348,10 +344,9 @@ export const ComposePost = ({
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
i18n,
|
||||
enableLargeVideoUploads,
|
||||
)
|
||||
},
|
||||
[i18n, agent, currentDid, composerDispatch, enableLargeVideoUploads],
|
||||
[i18n, agent, currentDid, composerDispatch],
|
||||
)
|
||||
|
||||
const onInitVideo = useNonReactiveCallback(() => {
|
||||
@@ -496,7 +491,6 @@ export const ComposePost = ({
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
i18n,
|
||||
enableLargeVideoUploads,
|
||||
)
|
||||
} catch (e) {
|
||||
logger.error('Failed to restore video from draft', {
|
||||
@@ -505,7 +499,7 @@ export const ComposePost = ({
|
||||
})
|
||||
}
|
||||
},
|
||||
[i18n, agent, currentDid, composerDispatch, enableLargeVideoUploads],
|
||||
[i18n, agent, currentDid, composerDispatch],
|
||||
)
|
||||
|
||||
const handleSelectDraft = useCallback(
|
||||
@@ -883,24 +877,12 @@ export const ComposePost = ({
|
||||
try {
|
||||
logger.info(`composer: posting...`)
|
||||
postUri = (
|
||||
await apilib.post(
|
||||
agent,
|
||||
queryClient,
|
||||
{
|
||||
thread: filteredThread,
|
||||
replyTo: replyTo?.uri,
|
||||
onStateChange: setPublishingStage,
|
||||
langs: currentLanguages,
|
||||
},
|
||||
{
|
||||
highResolutionImages: ax.features.enabled(
|
||||
ax.features.ImageUploadsHighResolution,
|
||||
),
|
||||
increasedBlobSizeLimit: ax.features.enabled(
|
||||
ax.features.ImageUploadsBlobSize2mbEnabled,
|
||||
),
|
||||
},
|
||||
)
|
||||
await apilib.post(agent, queryClient, {
|
||||
thread: filteredThread,
|
||||
replyTo: replyTo?.uri,
|
||||
onStateChange: setPublishingStage,
|
||||
langs: currentLanguages,
|
||||
})
|
||||
).uris[0]
|
||||
|
||||
/*
|
||||
|
||||
@@ -98,7 +98,8 @@ export const ExternalEmbedLink = ({
|
||||
uri,
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,11 @@ import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE} from '#/lib/constants'
|
||||
import {
|
||||
VIDEO_MAX_DURATION_MS,
|
||||
VIDEO_MAX_SIZE,
|
||||
VIDEO_MAX_SIZE_MB,
|
||||
} from '#/lib/constants'
|
||||
import {
|
||||
usePhotoLibraryPermission,
|
||||
useVideoLibraryPermission,
|
||||
@@ -431,7 +435,7 @@ export function SelectMediaButton({
|
||||
msg`You can only select one GIF at a time.`,
|
||||
),
|
||||
[SelectedAssetError.FileTooBig]: _(
|
||||
msg`One or more of your selected files are too large. Maximum size is 100 MB.`,
|
||||
msg`One or more of your selected files are too large. Maximum size is ${VIDEO_MAX_SIZE_MB} MB.`,
|
||||
),
|
||||
}[error]
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {VIDEO_MAX_SIZE_MB} from '#/lib/constants'
|
||||
import {compressVideo} from '#/lib/media/video/compress'
|
||||
import {
|
||||
ServerError,
|
||||
@@ -264,7 +265,6 @@ export async function processVideo(
|
||||
did: string,
|
||||
signal: AbortSignal,
|
||||
i18n: I18n,
|
||||
TEMP_enableLargeVideoUploads: boolean,
|
||||
) {
|
||||
let video: CompressedVideo | undefined
|
||||
try {
|
||||
@@ -273,14 +273,9 @@ export async function processVideo(
|
||||
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
||||
},
|
||||
signal,
|
||||
TEMP_enableLargeVideoUploads,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = getCompressErrorMessage(
|
||||
e,
|
||||
i18n,
|
||||
TEMP_enableLargeVideoUploads,
|
||||
)
|
||||
const message = getCompressErrorMessage(e, i18n)
|
||||
if (message !== null) {
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
@@ -309,7 +304,7 @@ export async function processVideo(
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
const message = getUploadErrorMessage(e, i18n, TEMP_enableLargeVideoUploads)
|
||||
const message = getUploadErrorMessage(e, i18n)
|
||||
if (message !== null) {
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
@@ -393,30 +388,20 @@ export async function processVideo(
|
||||
}
|
||||
}
|
||||
|
||||
function getCompressErrorMessage(
|
||||
e: unknown,
|
||||
i18n: I18n,
|
||||
TEMP_enableLargeVideoUploads: boolean,
|
||||
): string | null {
|
||||
const videoSize = TEMP_enableLargeVideoUploads ? 300 : 100
|
||||
function getCompressErrorMessage(e: unknown, i18n: I18n): string | null {
|
||||
if (e instanceof AbortError) {
|
||||
return null
|
||||
}
|
||||
if (e instanceof VideoTooLargeError) {
|
||||
return i18n._(
|
||||
msg`The selected video is larger than ${videoSize} MB. Please try again with a smaller file.`,
|
||||
msg`The selected video is larger than ${VIDEO_MAX_SIZE_MB} MB. Please try again with a smaller file.`,
|
||||
)
|
||||
}
|
||||
logger.error('Error compressing video', {safeMessage: e})
|
||||
return i18n._(msg`An error occurred while compressing the video.`)
|
||||
}
|
||||
|
||||
function getUploadErrorMessage(
|
||||
e: unknown,
|
||||
i18n: I18n,
|
||||
TEMP_enableLargeVideoUploads: boolean,
|
||||
): string | null {
|
||||
const videoSize = TEMP_enableLargeVideoUploads ? 300 : 100
|
||||
function getUploadErrorMessage(e: unknown, i18n: I18n): string | null {
|
||||
if (e instanceof AbortError) {
|
||||
return null
|
||||
}
|
||||
@@ -448,7 +433,7 @@ function getUploadErrorMessage(
|
||||
case 'file size (100000001 bytes) is larger than the maximum allowed size (100000000 bytes)':
|
||||
case 'file size (300000001 bytes) is larger than the maximum allowed size (300000000 bytes)':
|
||||
return i18n._(
|
||||
msg`The selected video is larger than ${videoSize} MB. Please try again with a smaller file.`,
|
||||
msg`The selected video is larger than ${VIDEO_MAX_SIZE_MB} MB. Please try again with a smaller file.`,
|
||||
)
|
||||
case 'Confirm your email address to upload videos':
|
||||
return i18n._(msg`Please confirm your email address to upload videos.`)
|
||||
|
||||
@@ -1093,6 +1093,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
||||
<MediaPreview.Embed
|
||||
embed={post.embed}
|
||||
style={styles.additionalPostImages}
|
||||
peekable
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
|
||||
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
|
||||
import {
|
||||
type DebouncedNavigationProp,
|
||||
useNavigationDeduped,
|
||||
@@ -21,6 +22,7 @@ import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {getTabState, TabState} from '#/lib/routes/helpers'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
@@ -81,6 +83,7 @@ export const Link = memo(function Link({
|
||||
const navigation = useNavigationDeduped()
|
||||
const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
|
||||
const openLink = useOpenLink()
|
||||
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||
|
||||
const onPress = useCallback(
|
||||
(e?: Event) => {
|
||||
@@ -92,11 +95,20 @@ export const Link = memo(function Link({
|
||||
sanitizeUrl(href),
|
||||
navigationAction,
|
||||
openLink,
|
||||
groupChatJoinIntent,
|
||||
e,
|
||||
)
|
||||
}
|
||||
},
|
||||
[closeModal, navigation, navigationAction, href, openLink, onBeforePress],
|
||||
[
|
||||
closeModal,
|
||||
navigation,
|
||||
navigationAction,
|
||||
href,
|
||||
openLink,
|
||||
onBeforePress,
|
||||
groupChatJoinIntent,
|
||||
],
|
||||
)
|
||||
|
||||
const accessibilityActionsWithActivate = [
|
||||
@@ -196,6 +208,7 @@ export const TextLink = memo(function TextLink({
|
||||
const {closeModal} = useModalControls()
|
||||
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
||||
const openLink = useOpenLink()
|
||||
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||
|
||||
if (!disableMismatchWarning && typeof text !== 'string') {
|
||||
console.error('Unable to detect mismatching label')
|
||||
@@ -238,6 +251,7 @@ export const TextLink = memo(function TextLink({
|
||||
sanitizeUrl(href),
|
||||
navigationAction,
|
||||
openLink,
|
||||
groupChatJoinIntent,
|
||||
e,
|
||||
)
|
||||
},
|
||||
@@ -252,6 +266,7 @@ export const TextLink = memo(function TextLink({
|
||||
navigationAction,
|
||||
openLink,
|
||||
linkWarningDialogControl,
|
||||
groupChatJoinIntent,
|
||||
],
|
||||
)
|
||||
const hrefAttrs = useMemo(() => {
|
||||
@@ -373,6 +388,7 @@ function onPressInner(
|
||||
href: string,
|
||||
navigationAction: 'push' | 'replace' | 'navigate' = 'push',
|
||||
openLink: (href: string) => void,
|
||||
groupChatJoinIntent: (code: string, uri?: string) => void,
|
||||
e?: Event,
|
||||
) {
|
||||
let shouldHandle = false
|
||||
@@ -400,6 +416,11 @@ function onPressInner(
|
||||
|
||||
if (shouldHandle) {
|
||||
href = convertBskyAppUrlIfNeeded(href)
|
||||
const chatInviteCode = getChatInviteCodeFromUrl(href)
|
||||
if (chatInviteCode) {
|
||||
groupChatJoinIntent(chatInviteCode, href)
|
||||
return
|
||||
}
|
||||
if (
|
||||
newTab ||
|
||||
href.startsWith('http') ||
|
||||
|
||||
@@ -345,7 +345,7 @@ function ListImpl<ItemT>(
|
||||
style,
|
||||
disableFullWindowScroll && {
|
||||
flex: 1,
|
||||
overflowY: 'scroll',
|
||||
overflowY: isWithinSplitView ? 'auto' : 'scroll',
|
||||
},
|
||||
]}
|
||||
ref={nativeRef as unknown as React.RefObject<View>}>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import Svg, {Path, type PathProps, type SvgProps} from 'react-native-svg'
|
||||
|
||||
import {useTheme} from '#/alf'
|
||||
|
||||
const ratio = 17 / 64
|
||||
|
||||
export function LogomarkWithType({
|
||||
fill,
|
||||
...rest
|
||||
}: {fill?: PathProps['fill']} & SvgProps) {
|
||||
const t = useTheme()
|
||||
const size = parseInt(`${rest.width || 32}`)
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 136 31"
|
||||
{...rest}
|
||||
width={size}
|
||||
height={Number(size) * ratio}>
|
||||
<Path
|
||||
d="M7.06102 1.95468C10.7957 4.79341 14.8125 10.5493 16.2874 13.6379C17.7624 10.5493 21.7792 4.79341 25.5138 1.95468C28.2085 -0.0935907 32.5749 -1.67844 32.5749 3.36465C32.5749 4.3718 32.0045 11.8254 31.67 13.0356C30.5072 17.2426 26.2702 18.3156 22.5011 17.6661C29.0892 18.8014 30.7651 22.5617 27.1457 26.3219C19.002 34.7826 16.2874 21.7959 16.2874 21.7959C16.2874 21.7959 13.5729 34.7826 5.42914 26.3219C1.80971 22.5617 3.48561 18.8014 10.0737 17.6661C6.30464 18.3156 2.06767 17.2426 0.904857 13.0356C0.570361 11.8254 0 4.3718 0 3.36465C0 -1.67844 4.36632 -0.0935907 7.06102 1.95468Z"
|
||||
fill={fill || t.atoms.text.color}
|
||||
/>
|
||||
<Path
|
||||
d="M52.8842 14.3087C55.1269 15.1125 56.3176 16.9695 56.3176 19.0483C56.3176 22.5684 53.964 24.7027 49.4508 24.7027H40.2305V4.9679H49.1462C53.4379 4.9679 55.5146 7.15757 55.5146 10.0679C55.5146 12.0081 54.6286 13.4217 52.8842 14.3087ZM48.8693 8.04453H43.9684V13.2H48.8693C50.7798 13.2 51.8043 12.2021 51.8043 10.5391C51.8043 9.01463 50.7521 8.04453 48.8693 8.04453ZM43.9684 21.5983H49.257C51.3613 21.5983 52.4965 20.6282 52.4965 18.882C52.4965 17.0527 51.4167 16.138 49.257 16.138H43.9684V21.5983Z"
|
||||
fill={fill || t.atoms.text.color}
|
||||
/>
|
||||
<Path
|
||||
d="M61.5003 24.7027H58.0115V4.9679H61.5003V24.7027Z"
|
||||
fill={fill || t.atoms.text.color}
|
||||
/>
|
||||
<Path
|
||||
d="M73.0679 18.4108V10.4005H76.5567V24.7027H73.1787V22.6239C72.0988 24.2315 70.6036 25.0353 68.6931 25.0353C65.675 25.0353 63.7091 23.2059 63.7091 19.8799V10.4005H67.1979V19.2978C67.1979 21.0994 68.084 22.0141 69.8837 22.0141C71.5727 22.0141 73.0679 20.7668 73.0679 18.4108Z"
|
||||
fill={fill || t.atoms.text.color}
|
||||
/>
|
||||
<Path
|
||||
d="M92.4368 17.801V18.6326H81.7489C81.9981 21.0994 83.3549 22.319 85.4592 22.319C87.0652 22.319 88.145 21.626 88.7265 20.2679H92.0768C91.3292 23.1782 88.8372 25.0353 85.4315 25.0353C83.2995 25.0353 81.5828 24.3424 80.2814 22.9842C78.9801 21.626 78.3156 19.8244 78.3156 17.5516C78.3156 15.3065 78.9524 13.5049 80.2538 12.119C81.5551 10.7608 83.2441 10.0679 85.3762 10.0679C87.5359 10.0679 89.2526 10.7885 90.5262 12.2021C91.7999 13.6157 92.4368 15.5005 92.4368 17.801ZM85.3485 12.7842C83.438 12.7842 82.1089 13.8929 81.7766 16.1934H88.948C88.6434 14.1146 87.3697 12.7842 85.3485 12.7842Z"
|
||||
fill={fill || t.atoms.text.color}
|
||||
/>
|
||||
<Path
|
||||
d="M100.169 25.0907C96.0159 25.0907 93.8285 23.4554 93.6347 20.157H97.0404C97.2342 21.9309 98.1203 22.5962 100.225 22.5962C102.107 22.5962 103.049 22.0141 103.049 20.8777C103.049 19.8521 102.384 19.3532 100.252 18.9929L98.6187 18.7157C95.4898 18.1891 93.9393 16.7478 93.9393 14.3918C93.9393 11.7032 96.0713 10.0679 99.8647 10.0679C103.935 10.0679 106.039 11.6755 106.178 14.9184H102.883C102.8 13.1722 101.775 12.5625 99.8647 12.5625C98.2033 12.5625 97.3727 13.1168 97.3727 14.2255C97.3727 15.2233 98.0926 15.6668 99.7539 15.9717L101.554 16.2489C105.015 16.8864 106.51 18.1614 106.51 20.6005C106.51 23.4831 104.184 25.0907 100.169 25.0907Z"
|
||||
fill={fill || t.atoms.text.color}
|
||||
/>
|
||||
<Path
|
||||
d="M121.822 24.7027H117.835L113.682 18.0505L111.522 20.2125V24.7027H108.089V4.9679H111.522V16.332L117.337 10.4005H121.49L116.091 15.8054L121.822 24.7027Z"
|
||||
fill={fill || t.atoms.text.color}
|
||||
/>
|
||||
<Path
|
||||
d="M130.934 14.0038L132.097 10.4005H135.751L130.241 25.8668C129.66 27.4467 128.94 28.5831 128.026 29.2206C127.113 29.8581 125.811 30.163 124.095 30.163C123.513 30.163 123.015 30.1353 122.572 30.0799V27.3358H123.901C125.479 27.3358 126.254 26.3657 126.254 25.0353C126.254 24.3701 126.033 23.4 125.59 22.1527L121.436 10.4005H125.202L126.365 13.976C127.223 16.6646 127.971 19.3255 128.635 21.9587C129.245 19.6858 130.02 17.025 130.934 14.0038Z"
|
||||
fill={fill || t.atoms.text.color}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ function patchSourceMapFilter(rules, pathPattern) {
|
||||
|
||||
module.exports = async function (env, argv) {
|
||||
env.babel = {
|
||||
dangerouslyAddModulePathsToTranspile: ['@bsky.app/expo'],
|
||||
dangerouslyAddModulePathsToTranspile: ['@bsky.app/expo', '@atproto/api'],
|
||||
}
|
||||
let config = await createExpoWebpackConfigAsync(env, argv)
|
||||
config = withAlias(config, {
|
||||
|
||||
Reference in New Issue
Block a user