Compare commits

...

41 Commits

Author SHA1 Message Date
Eric Bailey e8eac442b1 Disable overscroll on Android to fix settle discrepancy 2026-06-03 17:48:08 -05:00
Samuel Newman 57ae26fcaa Make extra sure the drawer gesture doesn't accidentally trigger on Android (#10713) 2026-06-03 13:57:45 -07:00
Spence Pope 0a5a994170 Fix image peek in carousels for quote posts (#10688)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-03 15:38:36 -05:00
DS Boyce f99837040e Compile i18n before running lint (#10711) 2026-06-03 13:21:13 -07:00
Samuel Newman 4367b9d047 [Android] Fix horizontal swipes being cancelled when leaving bounds (#10712) 2026-06-03 15:01:54 -05:00
DS Boyce 84d1785e12 Enable ESLint errors and suppress existing violations (#10490) 2026-06-03 11:27:44 -07:00
DS Boyce cdad723ea1 Hide accept button for locked chats (#10696) 2026-06-03 10:23:13 -07:00
DS Boyce 1b4c72f516 Mount message dialogs once at the list level (#10435)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-03 09:28:11 -07:00
Spence Pope b6650d8d5c Fix alt text selection in lightbox on Android (and iOS) (#10698)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-03 09:58:43 -05:00
Samuel Newman 21679b66dd Mention default <Text> style in CLAUDE.md (#10705) 2026-06-03 09:39:31 -05:00
RetroSunstar a046d8d96a Use square icons for labellers in Automation Label preview card (#10701) 2026-06-03 03:47:35 -07:00
Samuel Newman 00497964c1 [ALF] Set text to leading_snug by default (#10627) 2026-06-03 09:05:46 +03:00
pfrazee f4f66af31c Nightly source-language update 2026-06-03 03:21:33 +00:00
DS Boyce 3c06ecfa1a Transpile @atproto/api package for older browser support (#10700) 2026-06-02 21:13:34 -05:00
Samuel Newman 711334b34e Add iOS peek long-press context menu for image embeds (#10300)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 21:03:55 -05:00
Eric Bailey c88218ab87 Revert "[ALG-96] Don't fall back to search for onboarding user suggestions (#10680)" (#10699) 2026-06-02 16:28:48 -05:00
Spence Pope 0a7c087078 fix blur on image alt text background (#10658) 2026-06-02 16:01:16 -05:00
DS Boyce 73dae02df3 Create screen for managing chat requests (#10635) 2026-06-02 13:28:06 -07:00
DS Boyce 75490c3cdf Don't show Suggested heading if there are no suggestions (#10695) 2026-06-02 13:27:43 -07:00
Eric Bailey b87a51b485 Prefer opengraph image, fall back to Standard Site coverImage (#10694) 2026-06-02 15:07:14 -05:00
DS Boyce 56496520d6 Create logged-out view for group chat invites (#10598)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-02 12:02:49 -07:00
dependabot[bot] d08ab487d8 Bump actions/create-github-app-token from 1 to 3 (#10691)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 11:57:43 -07:00
dependabot[bot] a2c8b47b87 Bump marocchino/sticky-pull-request-comment from 2 to 3 (#10692)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 11:56:46 -07:00
dependabot[bot] 8e5ac6790f Bump slackapi/slack-github-action from 2.1.1 to 3.0.3 (#10693)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 11:55:39 -07:00
Austin McKinley 53fa6b2493 ci: add dependabot config with 7-day cooldown (#10475)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 11:04:28 -07:00
Eric Bailey 61e10f7e0b Remove image upload flags (#10687) 2026-06-02 12:42:10 -05:00
Eric Bailey 59765138ce [ALG-96] Don't fall back to search for onboarding user suggestions (#10680) 2026-06-02 12:29:24 -05:00
DS Boyce dfa82904ec Run GitHub actions in parallel (#10509)
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:24:55 -05:00
DS Boyce bdbf31a254 Use expo/expo-github-action v9 (#10689) 2026-06-02 09:47:22 -07:00
Samuel Newman 2b31047b0d Fix scrollbar behaviour in splitview (#10685) 2026-06-02 19:41:48 +03:00
Samuel Newman fd9f988b90 Ungate video upload size change (#10683) 2026-06-02 07:06:47 -07:00
Naresh Rawat cbf8e95bdb Message sent time is not updated immediately in ChatList screen (#7768) 2026-06-02 04:25:37 -07:00
Samuel Newman 771fd5ddf6 [Chat] Tweak styles in invite link dialog (#10638) 2026-06-02 03:23:51 -07:00
pfrazee 26e48e3876 Nightly source-language update 2026-06-02 03:22:13 +00:00
Samuel Newman 2abfba9771 Remove unused slider dep (#10618) 2026-06-01 15:36:34 -07:00
Eric Bailey 0dfd7cfa12 Update mod SDK (#10682) 2026-06-01 17:26:50 -05:00
Samuel Newman 237232f96c Update date-fns to latest (#10545) 2026-06-01 15:21:50 -07:00
Samuel Newman db1c4dc6e3 Update Intl polyfills (#10587)
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2026-06-01 15:03:53 -07:00
Iñigo Cilveti 751eda59fd Fix duplicate haptic feedback when liking posts (#10678) 2026-06-01 14:40:48 -07:00
Samuel Newman 62d1e88ce1 Fix Android crash in chat keyboard scroll (#10673)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:32:41 -07:00
DS Boyce ba24c7c357 Display join requests on chat list (#10679) 2026-06-01 14:32:36 -07:00
121 changed files with 6188 additions and 1671 deletions
+1
View File
@@ -0,0 +1 @@
alt-blur@3076a2b5a593b0d7b37198b5aebfe3a222afd52d
+44
View File
@@ -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]
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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
+10 -10
View File
@@ -71,23 +71,23 @@ jobs:
profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }}
- name: Lint check
run: pnpm lint
- name: Prettier check
run: pnpm prettier --check .
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
run: pnpm lint
- name: Prettier check
run: pnpm prettier --check .
- name: Type check
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
+54 -13
View File
@@ -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, typecheck]
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,17 +62,33 @@ 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: Check & compile i18n
run: pnpm intl:build
- name: Type check
run: pnpm typecheck
- 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"
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 +108,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"
+7 -7
View File
@@ -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 }}
@@ -128,20 +128,20 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint check
run: pnpm lint
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
run: pnpm lint
- name: Type check
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 }}
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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 }}
+7 -3
View File
@@ -206,7 +206,7 @@ function MyComponent() {
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
</View>
)
}
@@ -414,12 +414,16 @@ import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
// will almost certainly contain emoji, so only omit it when the text is static and
// does not contain an emoji
<Text emoji>Hello! 👋</Text>
```
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
### TextField
```tsx
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+1
View File
@@ -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)
+1828 -6
View File
File diff suppressed because it is too large Load Diff
+18 -20
View File
@@ -134,11 +134,10 @@ export default defineConfig(
'react-native/no-inline-styles': 'off',
...reactNativeA11y.configs.all.rules,
'react-compiler/react-compiler': 'warn',
// TODO: Fix these and set to error
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/refs': 'warn',
'react-hooks/immutability': 'warn',
'react-hooks/set-state-in-effect': 'error',
'react-hooks/purity': 'error',
'react-hooks/refs': 'error',
'react-hooks/immutability': 'error',
/**
* Import sorting
@@ -235,9 +234,8 @@ export default defineConfig(
},
],
/**
* Maintain previous behavior - these are stricter in typescript-eslint
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
* nit-picky
* Maintain previous behavior via eslint-suppressions.json - these are
* stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
*/
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/ban-ts-comment': 'off',
@@ -247,18 +245,18 @@ export default defineConfig(
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-misused-promises': 'warn',
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
'@typescript-eslint/no-redundant-type-constituents': 'warn',
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
'@typescript-eslint/await-thenable': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-redundant-type-constituents': 'error',
'@typescript-eslint/no-duplicate-type-constituents': 'error',
'@typescript-eslint/no-base-to-string': 'error',
'@typescript-eslint/prefer-promise-reject-errors': 'error',
'@typescript-eslint/await-thenable': 'error',
'no-restricted-imports': [
'error',
@@ -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) {
+9 -8
View File
@@ -89,10 +89,11 @@
"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.6",
"@atproto/api": "0.20.8",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
@@ -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",
@@ -112,16 +114,15 @@
"@expo/webpack-config": "^19.0.1",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
"@formatjs/intl-displaynames": "^6.8.13",
"@formatjs/intl-locale": "^4.2.13",
"@formatjs/intl-numberformat": "^8.15.6",
"@formatjs/intl-pluralrules": "^5.4.6",
"@formatjs/intl-displaynames": "^7.3.9",
"@formatjs/intl-locale": "^5.3.8",
"@formatjs/intl-numberformat": "^9.3.10",
"@formatjs/intl-pluralrules": "^6.3.9",
"@growthbook/growthbook": "^1.6.5",
"@growthbook/growthbook-react": "^1.6.5",
"@ipld/dag-cbor": "^9.2.7",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.5",
@@ -150,7 +151,7 @@
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
"date-fns": "^2.30.0",
"date-fns": "^4.4.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
@@ -1,5 +1,5 @@
diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da84061fd7f77c 100644
index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1a01093e7909973cef5268f999158df389e77634 100644
--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
+++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
@@ -1,8 +1,6 @@
@@ -19,7 +19,7 @@ index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da8406
inverted,
keyboardLiftBehavior,
freeze,
@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
@@ -62,20 +59,23 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
(target: number) => {
"worklet";
@@ -37,6 +37,15 @@ index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da8406
+ // otherwise the native ScrollView clamps contentOffset to the old
+ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android).
+ requestAnimationFrame(() => {
+ // The ScrollView can detach between scheduling this frame and now (e.g.
+ // navigating away from the screen, or the list re-creating its scroll
+ // component). Once detached, scrollViewRef() resolves to null, and on
+ // Paper reanimated's scrollTo hands that null to the native
+ // _scrollToPaper HostFunction, which throws "Value is null, expected a
+ // number". Re-check the ref now that the frame has arrived.
+ if (scrollViewRef() == null) {
+ return;
+ }
scrollTo(scrollViewRef, 0, target, false);
- }
+ });
+75 -95
View File
@@ -228,7 +228,7 @@ patchedDependencies:
react-native-compressor@1.13.0: 58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925
react-native-date-picker@5.0.13: f2a6697da7a7ca79b4f39efda142eee1b82db6de3aa5010ad4bd59df41fe2ce1
react-native-drawer-layout@4.2.3: 74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130
react-native-keyboard-controller@1.21.8: 642d128c971380a1858f7a938dd715d6eb3bbc260c93188b65580d92e2226afe
react-native-keyboard-controller@1.21.8: 2f6791837622fb391c3e41fc044b91ddedbe503da14da86165ee0a73924b05f1
react-native-pager-view@6.8.0: 0979d6fe1e2fa43b2784cc0b5e0ff0117d53b53423e85ee5855eefdc41a00108
react-native-reanimated@3.19.1: 97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6
react-native-svg@15.12.1: 31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9
@@ -242,8 +242,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.6
version: 0.20.6
specifier: 0.20.8
version: 0.20.8
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -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)
@@ -302,17 +305,17 @@ importers:
specifier: ^2.0.8
version: 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@formatjs/intl-displaynames':
specifier: ^6.8.13
version: 6.8.13
specifier: ^7.3.9
version: 7.3.9
'@formatjs/intl-locale':
specifier: ^4.2.13
version: 4.2.13
specifier: ^5.3.8
version: 5.3.8
'@formatjs/intl-numberformat':
specifier: ^8.15.6
version: 8.15.6
specifier: ^9.3.10
version: 9.3.10
'@formatjs/intl-pluralrules':
specifier: ^5.4.6
version: 5.4.6
specifier: ^6.3.9
version: 6.3.9
'@growthbook/growthbook':
specifier: ^1.6.5
version: 1.6.5
@@ -328,9 +331,6 @@ importers:
'@lingui/react':
specifier: ^5.9.2
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
'@miblanchard/react-native-slider':
specifier: ^2.6.0
version: 2.6.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)
'@mozzius/expo-dynamic-app-icon':
specifier: ^1.8.0
version: 1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -416,8 +416,8 @@ importers:
specifier: ^2.0.3
version: 2.0.3
date-fns:
specifier: ^2.30.0
version: 2.30.0
specifier: ^4.4.0
version: 4.4.0
email-validator:
specifier: ^2.0.4
version: 2.0.4
@@ -633,7 +633,7 @@ importers:
version: 2.28.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-keyboard-controller:
specifier: ^1.21.8
version: 1.21.8(patch_hash=642d128c971380a1858f7a938dd715d6eb3bbc260c93188b65580d92e2226afe)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
version: 1.21.8(patch_hash=2f6791837622fb391c3e41fc044b91ddedbe503da14da86165ee0a73924b05f1)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.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-pager-view:
specifier: 6.8.0
version: 6.8.0(patch_hash=0979d6fe1e2fa43b2784cc0b5e0ff0117d53b53423e85ee5855eefdc41a00108)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -877,8 +877,8 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.6':
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
'@atproto/api@0.20.8':
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -1652,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:
@@ -2065,33 +2072,32 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@formatjs/ecma402-abstract@2.3.6':
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
'@formatjs/bigdecimal@0.2.5':
resolution: {integrity: sha512-2XTKNrZRaCUyXK2976wfutqxMBuPO/S/zbJnQdysLI2Zy5mWPVNVEkE6tsTcSVWSE7DgO88t8DtBy+uf3I8bxg==}
'@formatjs/fast-memoize@2.2.7':
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
'@formatjs/fast-memoize@3.1.5':
resolution: {integrity: sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==}
'@formatjs/intl-displaynames@6.8.13':
resolution: {integrity: sha512-VbY7BdYJX5eURVKLk2grndUQtnbCLNbcJId/Sb/PsX7fWXiqWvg7qt/mecVHRzqoSEoGCQToKDxzpJj8RC0s3g==}
'@formatjs/intl-displaynames@7.3.9':
resolution: {integrity: sha512-UELvrHS171ElQW9iMCGqMR/HmUYQPYkYSz1A/SSkQtJ62ZH7aUWC1QnXPxL0CInQmxa/MgC7oE402XBoTe5nTQ==}
'@formatjs/intl-enumerator@1.8.12':
resolution: {integrity: sha512-rT2hnyQ0q6RDG1jISMH+MP6wlHQLUC81C0BeYEzabJb48S4tU2HGCPUxsOqMa3vDAUmpnz9Kw5vYTD5hTkOVgg==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
'@formatjs/intl-getcanonicallocales@3.2.9':
resolution: {integrity: sha512-b1dLzhFSeccG5X1PY2KodVFRGd0p6tBPc7pRBJaW6A7+fMeZnwHOAJLkMsGNDyvM9XailkkstE4jzmNa1GVh5w==}
'@formatjs/intl-getcanonicallocales@2.5.6':
resolution: {integrity: sha512-CnBbc4St61RL06gDXlCZG08Gt41uiySgsdZNBExh8/c0FBONJCrAlQ9FsyalUdq9ze0nCeknJtMmO8JnB9xHgQ==}
'@formatjs/intl-locale@5.3.8':
resolution: {integrity: sha512-pJgqQlg4zFoyCiVMXlKPBWpY7fAxvsUiShNmB/l8BW2ZwmFMefMWCrDzgocSH9WR+Z3pkihfjh1XbalCZhXc1Q==}
'@formatjs/intl-locale@4.2.13':
resolution: {integrity: sha512-Qcs0mP9JhEPCinPfpZ5JKzOa+VYDg94TQSFZozYuqG8R+hEGUTKyoyMqB5xi5QZT/hlB0CSgeNc/0fWMGtTFXA==}
'@formatjs/intl-localematcher@0.8.9':
resolution: {integrity: sha512-GmB0F/gYh4Hdl4rLWjgDsgT+x4pB54fkJeRh8kAZ4XFzKeCK8dGs+SBJWXO42QZtOUni+IDWKNuCw6wiL4lTvw==}
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
'@formatjs/intl-numberformat@9.3.10':
resolution: {integrity: sha512-wYboXVXZyTvuMBgwE0AxcXYxV8LM0IkH+lfCoBlRVNjCwNzdHVbRcUxDRHE+HbFkQ6/zWFkyguvIP5y3PAt+jw==}
'@formatjs/intl-numberformat@8.15.6':
resolution: {integrity: sha512-htynTNKm5WOnbR521tNSMkWzX3yO6Z77qjOxvRujh5/A/UBKeoNElyuKCJltizdx3X33QNWQZC4aWjLKcccyeQ==}
'@formatjs/intl-pluralrules@6.3.9':
resolution: {integrity: sha512-0405uU1npNs5/N04tfIQ3pme96Xb51tEkCBTBKy8HMjAsssbhnK70gf8v3ogj1WqHf7WFRoFZu8clQTsvwBQNg==}
'@formatjs/intl-pluralrules@5.4.6':
resolution: {integrity: sha512-2HlOq+c7KsSps829SJ3B5987coX5mzKx9NbPcNwQ07eq8FBHgB3HfMoxt5HvLsdk4oQwCjAEnocbtd+wVwZ2Kg==}
'@formatjs/intl-supportedvaluesof@2.3.7':
resolution: {integrity: sha512-ZTXEKijV5H08z19odngZPsXlmGndfukHuVnjd9qyER77XSk/yJwDMiSjoJYY5C5WxFs2UN4s/EMtu75e6Tp3ug==}
'@gorhom/portal@1.0.14':
resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==}
@@ -2321,12 +2327,6 @@ packages:
'@messageformat/parser@5.1.1':
resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==}
'@miblanchard/react-native-slider@2.6.0':
resolution: {integrity: sha512-o7hk/f/8vkqh6QNR5L52m+ws846fQeD/qNCC9CCSRdBqjq66KiCgbxzlhRzKM/gbtxcvMYMIEEJ1yes5cr6I3A==}
peerDependencies:
react: '>=16.8'
react-native: '>=0.59'
'@mozzius/expo-dynamic-app-icon@1.8.1':
resolution: {integrity: sha512-JWNY9gw06s+q54b2SqWf6BEo7IYuJCtjJDtca+wTo6kP5dH/wYK85JdLrMbdy+cwOMScEY85uU6Mjn0wkWTLnw==}
peerDependencies:
@@ -4789,13 +4789,12 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
date-fns@3.6.0:
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debounce@1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
@@ -9494,7 +9493,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.6':
'@atproto/api@0.20.8':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
@@ -10472,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
@@ -11055,56 +11060,38 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/bigdecimal@0.2.5': {}
'@formatjs/fast-memoize@2.2.7':
dependencies:
tslib: 2.8.1
'@formatjs/fast-memoize@3.1.5': {}
'@formatjs/intl-displaynames@6.8.13':
'@formatjs/intl-displaynames@7.3.9':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/intl-localematcher': 0.6.2
tslib: 2.8.1
'@formatjs/intl-localematcher': 0.8.9
'@formatjs/intl-enumerator@1.8.12':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
tslib: 2.8.1
'@formatjs/intl-getcanonicallocales@3.2.9': {}
'@formatjs/intl-getcanonicallocales@2.5.6':
'@formatjs/intl-locale@5.3.8':
dependencies:
tslib: 2.8.1
'@formatjs/intl-getcanonicallocales': 3.2.9
'@formatjs/intl-supportedvaluesof': 2.3.7
'@formatjs/intl-locale@4.2.13':
'@formatjs/intl-localematcher@0.8.9':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/intl-enumerator': 1.8.12
'@formatjs/intl-getcanonicallocales': 2.5.6
tslib: 2.8.1
'@formatjs/fast-memoize': 3.1.5
'@formatjs/intl-localematcher@0.6.2':
'@formatjs/intl-numberformat@9.3.10':
dependencies:
tslib: 2.8.1
'@formatjs/bigdecimal': 0.2.5
'@formatjs/intl-localematcher': 0.8.9
'@formatjs/intl-numberformat@8.15.6':
'@formatjs/intl-pluralrules@6.3.9':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/bigdecimal': 0.2.5
'@formatjs/intl-localematcher': 0.8.9
'@formatjs/intl-pluralrules@5.4.6':
'@formatjs/intl-supportedvaluesof@2.3.7':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/fast-memoize': 3.1.5
'@gorhom/portal@1.0.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
@@ -11462,11 +11449,6 @@ snapshots:
dependencies:
moo: 0.5.3
'@miblanchard/react-native-slider@2.6.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:
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)
'@mozzius/expo-dynamic-app-icon@1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
'@expo/image-utils': 0.8.12
@@ -14235,12 +14217,10 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.29.2
date-fns@3.6.0: {}
date-fns@4.4.0: {}
debounce@1.2.1: {}
debug@2.6.9:
@@ -17926,7 +17906,7 @@ snapshots:
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
react-native-keyboard-controller@1.21.8(patch_hash=642d128c971380a1858f7a938dd715d6eb3bbc260c93188b65580d92e2226afe)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.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-keyboard-controller@1.21.8(patch_hash=2f6791837622fb391c3e41fc044b91ddedbe503da14da86165ee0a73924b05f1)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.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:
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)
+6 -6
View File
@@ -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
View File
@@ -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>
+20
View File
@@ -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)
-3
View File
@@ -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',
}
+16 -10
View File
@@ -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 []
+3 -3
View File
@@ -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)
+5 -1
View File
@@ -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
+62 -39
View File
@@ -1,15 +1,10 @@
import {useRef} from 'react'
import {
LayoutAnimation,
Pressable,
ScrollView,
StyleSheet,
View,
} from 'react-native'
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 = {
@@ -37,36 +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]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Expand alt text`}
accessibilityHint=""
onPress={() => {
if (isMomentumScrolling.current) return
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
onToggleAltExpanded()
}}>
<Text
emoji
selectable
style={[a.text_sm, {color: t.palette.white}]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</Pressable>
</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>
)
@@ -74,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',
},
+10
View File
@@ -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,
],
)
+60 -10
View File
@@ -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)',
+1
View File
@@ -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>
)
}
+45 -16
View File
@@ -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,45 @@ 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.isWithinQuote
? 'square'
: 'constrained'
}
image={image}
onContainerRef={ref => {
singleContainerRef.current = ref
}}
onDimsChange={dims => {
singleDimsRef.current = dims
}}
onPress={(containerRef, dims) =>
onPress(0, [containerRef], [dims])
}
onPressIn={() => onPressIn(0)}
hideBadge={rest.isWithinQuote}
/>
</ImageContextMenu>
</View>
)
}
@@ -89,6 +117,7 @@ export function ImageEmbed({
onPress={onPress}
onPressIn={onPressIn}
viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
/>
</View>
)
-3
View File
@@ -11,7 +11,6 @@ import {useLingui} from '@lingui/react/macro'
import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
import {useHaptics} from '#/lib/haptics'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {type Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
@@ -93,7 +92,6 @@ let PostControls = ({
const requireAuth = useRequireAuth()
const {sendInteraction} = useFeedFeedbackContext()
const {captureAction} = useProgressGuideControls()
const playHaptic = useHaptics()
const isBlocked = Boolean(
post.author.viewer?.blocking ||
post.author.viewer?.blockedBy ||
@@ -116,7 +114,6 @@ let PostControls = ({
try {
setHasLikeIconBeenToggled(true)
if (!post.viewer?.like) {
playHaptic('Light')
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
+3 -1
View File
@@ -224,6 +224,7 @@ export function Basic({
cancelButtonCta,
confirmButtonCta,
onConfirm,
onClose,
confirmButtonColor,
showCancel = true,
}: React.PropsWithChildren<{
@@ -240,11 +241,12 @@ export function Basic({
* should NOT close the dialog as a side effect of this method.
*/
onConfirm: (e: GestureResponderEvent) => void
onClose?: () => void
confirmButtonColor?: ButtonColor
showCancel?: boolean
}>) {
return (
<Outer control={control} testID="confirmModal">
<Outer control={control} testID="confirmModal" onClose={onClose}>
<Content>
<TitleText>{title}</TitleText>
{description && <DescriptionText>{description}</DescriptionText>}
+1 -1
View File
@@ -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
+4 -2
View File
@@ -8,8 +8,10 @@ import {
type UninheritableButtonProps,
} from '#/components/Button'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {
CircleInfo_Stroke2_Corner0_Rounded as CircleInfo,
CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon,
} from '#/components/icons/CircleInfo'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {dismiss} from '#/components/Toast/sonner'
+1
View File
@@ -31,6 +31,7 @@ export function Text({
[
a.text_sm,
t.atoms.text,
a.leading_snug,
web(numberOfLines === 1 && numberOfLinesClippingFix),
style,
],
+1 -1
View File
@@ -219,7 +219,7 @@ export function AddMembersFlow({
}
}
if (searchText === '') {
if (searchText === '' && _items.length > 0) {
_items.unshift({
type: 'label',
key: 'suggested',
+6 -1
View File
@@ -33,14 +33,19 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({
control,
params,
currentScreen,
onClose,
}: {
control: Dialog.DialogControlProps
params: ReportDialogParams
currentScreen: 'list' | 'conversation'
onClose?: () => void
}): React.ReactNode {
const {t: l} = useLingui()
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={l`Would you like to block this user and/or delete this conversation?`}
+1
View File
@@ -316,6 +316,7 @@ export function InitiateChatFlow({
if (
searchText === '' &&
_items.length > 0 &&
(chatState === ChatState.NEW_CHAT ||
chatState === ChatState.NEW_GROUP_CHAT)
) {
+64 -112
View File
@@ -1,5 +1,5 @@
import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import {Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
type ChatBskyConvoDefs,
@@ -7,26 +7,21 @@ import {
RichText,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
@@ -48,11 +43,8 @@ export let MessageContextMenu = ({
const {t: l, i18n} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const blockOrDeleteControl = usePromptControl()
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate()
@@ -93,14 +85,6 @@ export let MessageContextMenu = ({
})
}, [ax, langPrefs.primaryLanguage, message.text, translate])
const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
.catch(() => Toast.show(l`Failed to delete message`))
}, [l, convo, message.id])
const onEmojiSelect = useCallback(
(emoji: string) => {
if (
@@ -128,104 +112,72 @@ export let MessageContextMenu = ({
const sender = senderProfile
return (
<>
<ContextMenu.Root>
{IS_NATIVE && reactionsAvailable && (
<ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
/>
</ContextMenu.AuxiliaryView>
)}
<ContextMenu.Trigger
label={l`Message options`}
contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Outer
<ContextMenu.Root>
{IS_NATIVE && reactionsAvailable && (
<ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={l`Translate`}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={l`Copy message text`}
onPress={onCopyMessage}>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
<ContextMenu.ItemText>
{l`Copy message text`}
</ContextMenu.ItemText>
</ContextMenu.Item>
</>
)}
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
/>
</ContextMenu.AuxiliaryView>
)}
<ContextMenu.Trigger
label={l`Message options`}
contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Outer
align={isFromSelf ? 'right' : 'left'}
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={l`Translate`}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={l`Copy message text`}
onPress={onCopyMessage}>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
<ContextMenu.ItemText>
{l`Copy message text`}
</ContextMenu.ItemText>
</ContextMenu.Item>
</>
)}
<ContextMenu.Item
destructive
testID="messageDropdownDeleteBtn"
label={l`Delete message for me`}
onPress={() => openDeleteMessage(message)}>
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
destructive
testID="messageDropdownDeleteBtn"
label={l`Delete message for me`}
onPress={() => deleteControl.open()}>
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
testID="messageDropdownReportBtn"
label={l`Report message`}
onPress={() => openReportMessage(message, senderProfile)}>
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
destructive
testID="messageDropdownReportBtn"
label={l`Report message`}
onPress={() => reportControl.open()}>
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
</ContextMenu.Item>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
<ReportDialog
control={reportControl}
subject={{
view: 'message',
convoId: convo.convo.view.id,
message,
}}
onAfterSubmit={() => {
if (sender) {
unstableCacheProfileView(queryClient, sender)
}
blockOrDeleteControl.open()
}}
/>
<AfterReportDialog
control={blockOrDeleteControl}
currentScreen="conversation"
params={{
convoId: convo.convo.view.id,
did: message.sender.did,
}}
/>
<Prompt.Basic
control={deleteControl}
title={l`Delete message`}
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
confirmButtonCta={l`Delete`}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
</>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
)
}
MessageContextMenu = memo(MessageContextMenu)
+8 -39
View File
@@ -40,8 +40,8 @@ import {useSession} from '#/state/session'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
@@ -49,7 +49,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {ReactionsDialog} from './ReactionsDialog'
import {groupReactions} from './ReactionsDialog'
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
const AVATAR_SIZE = 28
@@ -118,7 +118,7 @@ let MessageItem = ({
const {message} = item
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
const reactionsControl = useDialogControl()
const {openReactions} = useMessageDialogs()
const isPending = item.type === 'pending-message'
@@ -243,34 +243,10 @@ let MessageItem = ({
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
)
const groupedReactions = useMemo(() => {
const reactions = message.reactions ?? []
const grouped = new Map<
string,
{
key: string
value: string
senders: ChatBskyConvoDefs.ReactionViewSender[]
count: number
}
>()
for (const reaction of reactions) {
if (!reaction) continue
const existing = grouped.get(reaction.value)
if (existing) {
existing.senders.push(reaction.sender)
existing.count++
} else {
grouped.set(reaction.value, {
key: reaction.value,
value: reaction.value,
senders: [reaction.sender],
count: 1,
})
}
}
return Array.from(grouped.values())
}, [message.reactions])
const groupedReactions = useMemo(
() => groupReactions(message.reactions),
[message.reactions],
)
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
@@ -336,7 +312,7 @@ let MessageItem = ({
transform: [{translateY: -8}],
},
]}
onPress={isGroupChat ? reactionsControl.open : undefined}>
onPress={isGroupChat ? () => openReactions(message) : undefined}>
{groupedReactions.map(group => (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
@@ -377,13 +353,6 @@ let MessageItem = ({
</Pressable>
</View>
) : null}
<ReactionsDialog
control={reactionsControl}
relatedProfiles={relatedProfiles}
message={message}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</LayoutAnimationConfig>
)
+175
View File
@@ -0,0 +1,175 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import {LayoutAnimation} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useConvoActive} from '#/state/messages/convo'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useDialogControl} from '#/components/Dialog'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {ReactionsDialog} from '#/components/dms/ReactionsDialog'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
type MessageDialogsContextType = {
openDeleteMessage: (message: ChatBskyConvoDefs.MessageView) => void
openReportMessage: (
message: ChatBskyConvoDefs.MessageView,
senderProfile: bsky.profile.AnyProfileView | undefined,
) => void
openReactions: (message: ChatBskyConvoDefs.MessageView) => void
}
const Context = createContext<MessageDialogsContextType | null>(null)
export function useMessageDialogs() {
const ctx = useContext(Context)
if (!ctx) {
throw new Error('useMessageDialogs must be used within a MessageOverlays')
}
return ctx
}
export function MessageOverlays({children}: {children: React.ReactNode}) {
const {t: l} = useLingui()
const queryClient = useQueryClient()
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const afterReportControl = usePromptControl()
const reactionsControl = useDialogControl()
const [deleteTarget, setDeleteTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const [reportTarget, setReportTarget] = useState<{
message: ChatBskyConvoDefs.MessageView
senderProfile: bsky.profile.AnyProfileView | undefined
} | null>(null)
const [afterReportTarget, setAfterReportTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const [reactionsTarget, setReactionsTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const openDeleteMessage = useCallback(
(message: ChatBskyConvoDefs.MessageView) => {
setDeleteTarget(message)
deleteControl.open()
},
[deleteControl],
)
const openReportMessage = useCallback(
(
message: ChatBskyConvoDefs.MessageView,
senderProfile: bsky.profile.AnyProfileView | undefined,
) => {
setReportTarget({message, senderProfile})
reportControl.open()
},
[reportControl],
)
const openReactions = useCallback(
(message: ChatBskyConvoDefs.MessageView) => {
setReactionsTarget(message)
},
[],
)
// These dialogs are conditionally mounted, so we can't open them in the same
// tick that we set their targets - the control refs aren't attached yet. Open
// in an effect after the dialog has mounted.
useEffect(() => {
if (reactionsTarget) {
reactionsControl.open()
}
}, [reactionsTarget, reactionsControl])
useEffect(() => {
if (afterReportTarget) {
afterReportControl.open()
}
}, [afterReportTarget, afterReportControl])
const onConfirmDelete = useCallback(() => {
if (!deleteTarget) return
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(deleteTarget.id)
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
.catch(() => Toast.show(l`Failed to delete message`))
}, [l, convo, deleteTarget])
const onAfterReportSubmit = useCallback(() => {
if (!reportTarget) return
if (reportTarget.senderProfile) {
unstableCacheProfileView(queryClient, reportTarget.senderProfile)
}
setAfterReportTarget(reportTarget.message)
}, [queryClient, reportTarget])
const ctx = useMemo<MessageDialogsContextType>(
() => ({openDeleteMessage, openReportMessage, openReactions}),
[openDeleteMessage, openReportMessage, openReactions],
)
const reportSubject = reportTarget
? ({
view: 'message',
convoId: convo.convo.view.id,
message: reportTarget.message,
} as const)
: undefined
return (
<Context.Provider value={ctx}>
{children}
<ReportDialog
control={reportControl}
subject={reportSubject}
onAfterSubmit={onAfterReportSubmit}
onClose={() => setReportTarget(null)}
/>
{afterReportTarget && (
<AfterReportDialog
control={afterReportControl}
currentScreen="conversation"
params={{
convoId: convo.convo.view.id,
did: afterReportTarget.sender.did,
}}
onClose={() => setAfterReportTarget(null)}
/>
)}
{reactionsTarget && (
<ReactionsDialog
control={reactionsControl}
relatedProfiles={convo.relatedProfiles}
message={reactionsTarget}
onClose={() => setReactionsTarget(null)}
/>
)}
<Prompt.Basic
control={deleteControl}
title={l`Delete message`}
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
confirmButtonCta={l`Delete`}
confirmButtonColor="negative"
onConfirm={onConfirmDelete}
onClose={() => setDeleteTarget(null)}
/>
</Context.Provider>
)
}
+32 -6
View File
@@ -1,4 +1,4 @@
import {useRef, useState} from 'react'
import {useMemo, useRef, useState} from 'react'
import {
LayoutAnimation,
Pressable,
@@ -37,14 +37,12 @@ export function ReactionsDialog({
control,
relatedProfiles,
message,
reactions,
groupedReactions,
onClose,
}: {
control: Dialog.DialogControlProps
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
message: ChatBskyConvoDefs.MessageView
reactions?: ChatBskyConvoDefs.ReactionView[]
groupedReactions?: Reaction[]
onClose?: () => void
}) {
const {t: l} = useLingui()
@@ -54,6 +52,9 @@ export function ReactionsDialog({
const [selected, setSelected] = useState('all')
const reactions = message.reactions
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
const filteredReactions = reactions?.filter(
r => selected === 'all' || r.value === selected,
)
@@ -78,7 +79,10 @@ export function ReactionsDialog({
return (
<Dialog.Outer
control={control}
onClose={() => setSelected('all')}
onClose={() => {
setSelected('all')
onClose?.()
}}
nativeOptions={{
preventExpansion: true,
minHeight: screenHeight / 2,
@@ -388,3 +392,25 @@ function ReactionTab({
</Pressable>
)
}
export function groupReactions(
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
): Reaction[] {
const grouped = new Map<string, Reaction>()
for (const reaction of reactions ?? []) {
if (!reaction) continue
const existing = grouped.get(reaction.value)
if (existing) {
existing.senders.push(reaction.sender)
existing.count++
} else {
grouped.set(reaction.value, {
key: reaction.value,
value: reaction.value,
senders: [reaction.sender],
count: 1,
})
}
}
return Array.from(grouped.values())
}
+7 -3
View File
@@ -212,9 +212,12 @@ export function Item({
)
const highlightStyle = highlightRow
? selected
? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}]
: [a.rounded_full, a.p_md]
? [
a.rounded_full,
a.p_md,
hovered && t.atoms.bg_contrast_25,
selected && {backgroundColor: t.palette.primary_50},
]
: null
return (
@@ -588,6 +591,7 @@ export function RadioWithLabel({
style={[
a.font_medium,
a.flex_1,
a.text_md,
a.leading_tight,
selected ? t.atoms.text : t.atoms.text_contrast_high,
]}>
@@ -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
}
@@ -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)
+25 -5
View File
@@ -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
}
+15 -2
View File
@@ -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"
+159 -139
View File
@@ -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,10 +36,11 @@ 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'
import {IS_WEB} from '#/env'
import {IS_ANDROID, IS_WEB} from '#/env'
export * from './const'
export * from './maybeApplyGalleryOffsetStyles'
@@ -53,6 +54,7 @@ interface GalleryProps {
) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
}
const Context = createContext<{
@@ -96,6 +98,7 @@ export function Gallery({
onPress,
onPressIn,
viewContext,
isWithinQuote,
}: GalleryProps) {
const {t: l} = useLingui()
const ax = useAnalytics()
@@ -103,14 +106,21 @@ export function Gallery({
const largeAltBadge = useLargeAltBadgeEnabled()
const bps = useBreakpoints()
const window = useWindowDimensions()
const isWithinQuote =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
const hideBadges = isWithinQuote
const contentHeight = useMemo(() => {
if (isWithinChat) {
return 120
}
if (isWithinQuote) {
if (bps.gtMobile) {
return 220
} else if (bps.gtPhone) {
return 190
} else {
return 150
}
}
if (bps.gtMobile) {
return 300
} else if (bps.gtPhone) {
@@ -118,7 +128,7 @@ export function Gallery({
} else {
return 200
}
}, [bps, isWithinChat])
}, [bps, isWithinChat, isWithinQuote])
/*
* Container overflow styles
@@ -219,7 +229,7 @@ export function Gallery({
crop={
viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
: isWithinQuote
? 'square'
: 'constrained'
}
@@ -228,9 +238,7 @@ export function Gallery({
onPress?.(index, [containerRef], [dims])
}
onPressIn={() => onPressIn?.(index)}
hideBadge={
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
hideBadge={isWithinQuote}
/>
))}
</View>
@@ -256,6 +264,9 @@ export function Gallery({
aria-label={l`Image gallery, ${images.length} images`}
horizontal
pagingEnabled={false}
// Disable Android's stretch overscroll, which can leave the carousel
// settled just off the left edge instead of aligned to x = 0
overScrollMode={IS_ANDROID ? 'never' : 'auto'}
showsHorizontalScrollIndicator={false}
directionalLockEnabled
nestedScrollEnabled
@@ -264,6 +275,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 +314,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 +389,7 @@ function GalleryImage({
onThumbDims,
onPress,
onPressIn,
onPreviewPress,
}: {
contentHeight: number
image: AppBskyEmbedImages.ViewImage
@@ -391,6 +403,7 @@ function GalleryImage({
onThumbDims: (index: number, dims: Dimensions) => void
onPress?: () => void
onPressIn?: () => void
onPreviewPress?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -416,124 +429,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>
)
}
+56 -38
View File
@@ -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>
)}
</>
)
}
+69 -2
View File
@@ -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>
)
}
@@ -75,9 +75,11 @@ export function ReportDialog(
() => (props.subject ? parseReportSubject(props.subject) : undefined),
[props.subject],
)
const propsOnClose = props.onClose
const onClose = useCallback(() => {
ax.metric('reportDialog:close', {})
}, [ax])
propsOnClose?.()
}, [ax, propsOnClose])
return (
<Dialog.Outer control={props.control} onClose={onClose}>
<Dialog.Handle />
@@ -88,4 +88,8 @@ export type ReportDialogProps = {
* Called if the report was successfully submitted.
*/
onAfterSubmit?: () => void
/**
* Called after the dialog finishes closing.
*/
onClose?: () => void
}
+2 -21
View File
@@ -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 {
+4
View File
@@ -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,
}
+3 -2
View File
@@ -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',
+51
View File
@@ -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} =
+6 -6
View File
@@ -58,12 +58,12 @@ export function dateDiff(
if (diffSeconds < NOW) {
diff = {
value: 0,
unit: 'now' as DateDiff['unit'],
unit: 'now',
}
} else if (diffSeconds < MINUTE) {
diff = {
value: diffSeconds,
unit: 'second' as DateDiff['unit'],
unit: 'second',
}
} else if (diffSeconds < HOUR) {
const value =
@@ -72,7 +72,7 @@ export function dateDiff(
: Math.floor(diffSeconds / MINUTE)
diff = {
value,
unit: 'minute' as DateDiff['unit'],
unit: 'minute',
}
} else if (diffSeconds < DAY) {
const value =
@@ -81,7 +81,7 @@ export function dateDiff(
: Math.floor(diffSeconds / HOUR)
diff = {
value,
unit: 'hour' as DateDiff['unit'],
unit: 'hour',
}
} else if (diffSeconds < MONTH_30) {
const value =
@@ -90,7 +90,7 @@ export function dateDiff(
: Math.floor(diffSeconds / DAY)
diff = {
value,
unit: 'day' as DateDiff['unit'],
unit: 'day',
}
} else {
const value =
@@ -99,7 +99,7 @@ export function dateDiff(
: Math.floor(diffSeconds / MONTH_30)
diff = {
value,
unit: 'month' as DateDiff['unit'],
unit: 'month',
}
}
-1
View File
@@ -12,7 +12,6 @@ export async function compressVideo(
opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
TEMP_enableLargeVideoUploads?: boolean
},
): Promise<CompressedVideo> {
const {onProgress, signal} = opts || {}
+3 -9
View File
@@ -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()
}
+2 -2
View File
@@ -19,8 +19,8 @@ export function makeProfileLink(
export function makeCustomFeedLink(
did: string,
rkey: string,
segment?: string | undefined,
feedCacheKey?: 'discover' | 'explore' | undefined,
segment?: string,
feedCacheKey?: 'discover' | 'explore',
) {
return (
[`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') +
+1
View File
@@ -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}
+22
View File
@@ -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
+207 -206
View File
@@ -1,16 +1,16 @@
// Don't remove -force from these because detection is VERY slow on low-end Android.
// https://github.com/formatjs/formatjs/issues/4463#issuecomment-2176070577
import '@formatjs/intl-locale/polyfill-force'
import '@formatjs/intl-pluralrules/polyfill-force'
import '@formatjs/intl-numberformat/polyfill-force'
import '@formatjs/intl-displaynames/polyfill-force'
import '@formatjs/intl-pluralrules/locale-data/en'
import '@formatjs/intl-numberformat/locale-data/en'
import '@formatjs/intl-displaynames/locale-data/en'
import '@formatjs/intl-locale/polyfill-force.js'
import '@formatjs/intl-pluralrules/polyfill-force.js'
import '@formatjs/intl-numberformat/polyfill-force.js'
import '@formatjs/intl-displaynames/polyfill-force.js'
import '@formatjs/intl-pluralrules/locale-data/en.js'
import '@formatjs/intl-numberformat/locale-data/en.js'
import '@formatjs/intl-displaynames/locale-data/en.js'
import {useEffect, useState} from 'react'
import {i18n} from '@lingui/core'
import defaultLocale from 'date-fns/locale/en-US'
import {enUS as defaultLocale} from 'date-fns/locale/en-US'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -64,399 +64,398 @@ export async function dynamicActivate(locale: AppLanguage) {
switch (locale) {
case AppLanguage.an: {
i18n.loadAndActivate({locale, messages: messagesAn})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/an'),
import('@formatjs/intl-numberformat/locale-data/es'),
import('@formatjs/intl-displaynames/locale-data/es'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/es').then(m => m.es),
import('@formatjs/intl-pluralrules/locale-data/an.js'),
import('@formatjs/intl-numberformat/locale-data/an.js'),
import('@formatjs/intl-displaynames/locale-data/an.js'),
])
return dateLocale
}
case AppLanguage.ast: {
i18n.loadAndActivate({locale, messages: messagesAst})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/ast'),
import('@formatjs/intl-numberformat/locale-data/ast'),
import('@formatjs/intl-displaynames/locale-data/ast'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/es').then(m => m.es),
import('@formatjs/intl-pluralrules/locale-data/ast.js'),
import('@formatjs/intl-numberformat/locale-data/ast.js'),
import('@formatjs/intl-displaynames/locale-data/ast.js'),
])
return dateLocale
}
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ca'),
import('@formatjs/intl-pluralrules/locale-data/ca'),
import('@formatjs/intl-numberformat/locale-data/ca'),
import('@formatjs/intl-displaynames/locale-data/ca'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ca').then(m => m.ca),
import('@formatjs/intl-pluralrules/locale-data/ca.js'),
import('@formatjs/intl-numberformat/locale-data/ca.js'),
import('@formatjs/intl-displaynames/locale-data/ca.js'),
])
return dateLocale
}
case AppLanguage.cy: {
i18n.loadAndActivate({locale, messages: messagesCy})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/cy'),
import('@formatjs/intl-pluralrules/locale-data/cy'),
import('@formatjs/intl-numberformat/locale-data/cy'),
import('@formatjs/intl-displaynames/locale-data/cy'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/cy').then(m => m.cy),
import('@formatjs/intl-pluralrules/locale-data/cy.js'),
import('@formatjs/intl-numberformat/locale-data/cy.js'),
import('@formatjs/intl-displaynames/locale-data/cy.js'),
])
return dateLocale
}
case AppLanguage.da: {
i18n.loadAndActivate({locale, messages: messagesDa})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/da'),
import('@formatjs/intl-pluralrules/locale-data/da'),
import('@formatjs/intl-numberformat/locale-data/da'),
import('@formatjs/intl-displaynames/locale-data/da'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/da').then(m => m.da),
import('@formatjs/intl-pluralrules/locale-data/da.js'),
import('@formatjs/intl-numberformat/locale-data/da.js'),
import('@formatjs/intl-displaynames/locale-data/da.js'),
])
return dateLocale
}
case AppLanguage.de: {
i18n.loadAndActivate({locale, messages: messagesDe})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/de'),
import('@formatjs/intl-pluralrules/locale-data/de'),
import('@formatjs/intl-numberformat/locale-data/de'),
import('@formatjs/intl-displaynames/locale-data/de'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/de').then(m => m.de),
import('@formatjs/intl-pluralrules/locale-data/de.js'),
import('@formatjs/intl-numberformat/locale-data/de.js'),
import('@formatjs/intl-displaynames/locale-data/de.js'),
])
return dateLocale
}
case AppLanguage.el: {
i18n.loadAndActivate({locale, messages: messagesEl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/el'),
import('@formatjs/intl-pluralrules/locale-data/el'),
import('@formatjs/intl-numberformat/locale-data/el'),
import('@formatjs/intl-displaynames/locale-data/el'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/el').then(m => m.el),
import('@formatjs/intl-pluralrules/locale-data/el.js'),
import('@formatjs/intl-numberformat/locale-data/el.js'),
import('@formatjs/intl-displaynames/locale-data/el.js'),
])
return dateLocale
}
case AppLanguage.en_GB: {
i18n.loadAndActivate({locale, messages: messagesEn_GB})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/en-GB'),
import('@formatjs/intl-pluralrules/locale-data/en'),
import('@formatjs/intl-numberformat/locale-data/en-GB'),
import('@formatjs/intl-displaynames/locale-data/en-GB'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/en-GB').then(m => m.enGB),
import('@formatjs/intl-pluralrules/locale-data/en.js'),
import('@formatjs/intl-numberformat/locale-data/en-GB.js'),
import('@formatjs/intl-displaynames/locale-data/en-GB.js'),
])
return dateLocale
}
case AppLanguage.eo: {
i18n.loadAndActivate({locale, messages: messagesEo})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/eo'),
import('@formatjs/intl-pluralrules/locale-data/eo'),
import('@formatjs/intl-numberformat/locale-data/eo'),
// borked, see https://github.com/bluesky-social/social-app/pull/9574
// import('@formatjs/intl-displaynames/locale-data/eo'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/eo').then(m => m.eo),
import('@formatjs/intl-pluralrules/locale-data/eo.js'),
import('@formatjs/intl-numberformat/locale-data/eo.js'),
import('@formatjs/intl-displaynames/locale-data/eo.js'),
])
return dateLocale
}
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/es'),
import('@formatjs/intl-numberformat/locale-data/es'),
import('@formatjs/intl-displaynames/locale-data/es'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/es').then(m => m.es),
import('@formatjs/intl-pluralrules/locale-data/es.js'),
import('@formatjs/intl-numberformat/locale-data/es.js'),
import('@formatjs/intl-displaynames/locale-data/es.js'),
])
return dateLocale
}
case AppLanguage.eu: {
i18n.loadAndActivate({locale, messages: messagesEu})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/eu'),
import('@formatjs/intl-pluralrules/locale-data/eu'),
import('@formatjs/intl-numberformat/locale-data/eu'),
import('@formatjs/intl-displaynames/locale-data/eu'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/eu').then(m => m.eu),
import('@formatjs/intl-pluralrules/locale-data/eu.js'),
import('@formatjs/intl-numberformat/locale-data/eu.js'),
import('@formatjs/intl-displaynames/locale-data/eu.js'),
])
return dateLocale
}
case AppLanguage.fi: {
i18n.loadAndActivate({locale, messages: messagesFi})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fi'),
import('@formatjs/intl-pluralrules/locale-data/fi'),
import('@formatjs/intl-numberformat/locale-data/fi'),
import('@formatjs/intl-displaynames/locale-data/fi'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/fi').then(m => m.fi),
import('@formatjs/intl-pluralrules/locale-data/fi.js'),
import('@formatjs/intl-numberformat/locale-data/fi.js'),
import('@formatjs/intl-displaynames/locale-data/fi.js'),
])
return dateLocale
}
case AppLanguage.fr: {
i18n.loadAndActivate({locale, messages: messagesFr})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fr'),
import('@formatjs/intl-pluralrules/locale-data/fr'),
import('@formatjs/intl-numberformat/locale-data/fr'),
import('@formatjs/intl-displaynames/locale-data/fr'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/fr').then(m => m.fr),
import('@formatjs/intl-pluralrules/locale-data/fr.js'),
import('@formatjs/intl-numberformat/locale-data/fr.js'),
import('@formatjs/intl-displaynames/locale-data/fr.js'),
])
return dateLocale
}
case AppLanguage.fy: {
i18n.loadAndActivate({locale, messages: messagesFy})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fy'),
import('@formatjs/intl-pluralrules/locale-data/fy'),
import('@formatjs/intl-numberformat/locale-data/fy'),
import('@formatjs/intl-displaynames/locale-data/fy'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/fy').then(m => m.fy),
import('@formatjs/intl-pluralrules/locale-data/fy.js'),
import('@formatjs/intl-numberformat/locale-data/fy.js'),
import('@formatjs/intl-displaynames/locale-data/fy.js'),
])
return dateLocale
}
case AppLanguage.ga: {
i18n.loadAndActivate({locale, messages: messagesGa})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ga'),
import('@formatjs/intl-numberformat/locale-data/ga'),
import('@formatjs/intl-displaynames/locale-data/ga'),
import('@formatjs/intl-pluralrules/locale-data/ga.js'),
import('@formatjs/intl-numberformat/locale-data/ga.js'),
import('@formatjs/intl-displaynames/locale-data/ga.js'),
])
return undefined
}
case AppLanguage.gd: {
i18n.loadAndActivate({locale, messages: messagesGd})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/gd'),
import('@formatjs/intl-pluralrules/locale-data/gd'),
import('@formatjs/intl-numberformat/locale-data/gd'),
import('@formatjs/intl-displaynames/locale-data/gd'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/gd').then(m => m.gd),
import('@formatjs/intl-pluralrules/locale-data/gd.js'),
import('@formatjs/intl-numberformat/locale-data/gd.js'),
import('@formatjs/intl-displaynames/locale-data/gd.js'),
])
return dateLocale
}
case AppLanguage.gl: {
i18n.loadAndActivate({locale, messages: messagesGl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/gl'),
import('@formatjs/intl-pluralrules/locale-data/gl'),
import('@formatjs/intl-numberformat/locale-data/gl'),
import('@formatjs/intl-displaynames/locale-data/gl'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/gl').then(m => m.gl),
import('@formatjs/intl-pluralrules/locale-data/gl.js'),
import('@formatjs/intl-numberformat/locale-data/gl.js'),
import('@formatjs/intl-displaynames/locale-data/gl.js'),
])
return dateLocale
}
case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesHi})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/hi'),
import('@formatjs/intl-pluralrules/locale-data/hi'),
import('@formatjs/intl-numberformat/locale-data/hi'),
import('@formatjs/intl-displaynames/locale-data/hi'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/hi').then(m => m.hi),
import('@formatjs/intl-pluralrules/locale-data/hi.js'),
import('@formatjs/intl-numberformat/locale-data/hi.js'),
import('@formatjs/intl-displaynames/locale-data/hi.js'),
])
return dateLocale
}
case AppLanguage.hu: {
i18n.loadAndActivate({locale, messages: messagesHu})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/hu'),
import('@formatjs/intl-pluralrules/locale-data/hu'),
import('@formatjs/intl-numberformat/locale-data/hu'),
import('@formatjs/intl-displaynames/locale-data/hu'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/hu').then(m => m.hu),
import('@formatjs/intl-pluralrules/locale-data/hu.js'),
import('@formatjs/intl-numberformat/locale-data/hu.js'),
import('@formatjs/intl-displaynames/locale-data/hu.js'),
])
return dateLocale
}
case AppLanguage.ia: {
i18n.loadAndActivate({locale, messages: messagesIa})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ia'),
import('@formatjs/intl-numberformat/locale-data/ia'),
import('@formatjs/intl-displaynames/locale-data/ia'),
import('@formatjs/intl-pluralrules/locale-data/ia.js'),
import('@formatjs/intl-numberformat/locale-data/ia.js'),
import('@formatjs/intl-displaynames/locale-data/ia.js'),
])
return undefined
}
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/id'),
import('@formatjs/intl-pluralrules/locale-data/id'),
import('@formatjs/intl-numberformat/locale-data/id'),
import('@formatjs/intl-displaynames/locale-data/id'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/id').then(m => m.id),
import('@formatjs/intl-pluralrules/locale-data/id.js'),
import('@formatjs/intl-numberformat/locale-data/id.js'),
import('@formatjs/intl-displaynames/locale-data/id.js'),
])
return dateLocale
}
case AppLanguage.it: {
i18n.loadAndActivate({locale, messages: messagesIt})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/it'),
import('@formatjs/intl-pluralrules/locale-data/it'),
import('@formatjs/intl-numberformat/locale-data/it'),
import('@formatjs/intl-displaynames/locale-data/it'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/it').then(m => m.it),
import('@formatjs/intl-pluralrules/locale-data/it.js'),
import('@formatjs/intl-numberformat/locale-data/it.js'),
import('@formatjs/intl-displaynames/locale-data/it.js'),
])
return dateLocale
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ja'),
import('@formatjs/intl-pluralrules/locale-data/ja'),
import('@formatjs/intl-numberformat/locale-data/ja'),
import('@formatjs/intl-displaynames/locale-data/ja'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ja').then(m => m.ja),
import('@formatjs/intl-pluralrules/locale-data/ja.js'),
import('@formatjs/intl-numberformat/locale-data/ja.js'),
import('@formatjs/intl-displaynames/locale-data/ja.js'),
])
return dateLocale
}
case AppLanguage.km: {
i18n.loadAndActivate({locale, messages: messagesKm})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/km'),
import('@formatjs/intl-pluralrules/locale-data/km'),
import('@formatjs/intl-numberformat/locale-data/km'),
import('@formatjs/intl-displaynames/locale-data/km'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/km').then(m => m.km),
import('@formatjs/intl-pluralrules/locale-data/km.js'),
import('@formatjs/intl-numberformat/locale-data/km.js'),
import('@formatjs/intl-displaynames/locale-data/km.js'),
])
return dateLocale
}
case AppLanguage.ko: {
i18n.loadAndActivate({locale, messages: messagesKo})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ko'),
import('@formatjs/intl-pluralrules/locale-data/ko'),
import('@formatjs/intl-numberformat/locale-data/ko'),
import('@formatjs/intl-displaynames/locale-data/ko'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ko').then(m => m.ko),
import('@formatjs/intl-pluralrules/locale-data/ko.js'),
import('@formatjs/intl-numberformat/locale-data/ko.js'),
import('@formatjs/intl-displaynames/locale-data/ko.js'),
])
return dateLocale
}
case AppLanguage.ne: {
i18n.loadAndActivate({locale, messages: messagesNe})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ne'),
import('@formatjs/intl-numberformat/locale-data/ne'),
import('@formatjs/intl-displaynames/locale-data/ne'),
import('@formatjs/intl-pluralrules/locale-data/ne.js'),
import('@formatjs/intl-numberformat/locale-data/ne.js'),
import('@formatjs/intl-displaynames/locale-data/ne.js'),
])
return undefined
}
case AppLanguage.nl: {
i18n.loadAndActivate({locale, messages: messagesNl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/nl'),
import('@formatjs/intl-pluralrules/locale-data/nl'),
import('@formatjs/intl-numberformat/locale-data/nl'),
import('@formatjs/intl-displaynames/locale-data/nl'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/nl').then(m => m.nl),
import('@formatjs/intl-pluralrules/locale-data/nl.js'),
import('@formatjs/intl-numberformat/locale-data/nl.js'),
import('@formatjs/intl-displaynames/locale-data/nl.js'),
])
return dateLocale
}
case AppLanguage.pl: {
i18n.loadAndActivate({locale, messages: messagesPl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pl'),
import('@formatjs/intl-pluralrules/locale-data/pl'),
import('@formatjs/intl-numberformat/locale-data/pl'),
import('@formatjs/intl-displaynames/locale-data/pl'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/pl').then(m => m.pl),
import('@formatjs/intl-pluralrules/locale-data/pl.js'),
import('@formatjs/intl-numberformat/locale-data/pl.js'),
import('@formatjs/intl-displaynames/locale-data/pl.js'),
])
return dateLocale
}
case AppLanguage.pt_BR: {
i18n.loadAndActivate({locale, messages: messagesPt_BR})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pt-BR'),
import('@formatjs/intl-pluralrules/locale-data/pt'),
import('@formatjs/intl-numberformat/locale-data/pt'),
import('@formatjs/intl-displaynames/locale-data/pt'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/pt-BR').then(m => m.ptBR),
import('@formatjs/intl-pluralrules/locale-data/pt.js'),
import('@formatjs/intl-numberformat/locale-data/pt.js'),
import('@formatjs/intl-displaynames/locale-data/pt.js'),
])
return dateLocale
}
case AppLanguage.pt_PT: {
i18n.loadAndActivate({locale, messages: messagesPt_PT})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pt'),
import('@formatjs/intl-pluralrules/locale-data/pt-PT'),
import('@formatjs/intl-numberformat/locale-data/pt-PT'),
import('@formatjs/intl-displaynames/locale-data/pt-PT'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/pt').then(m => m.pt),
import('@formatjs/intl-pluralrules/locale-data/pt-PT.js'),
import('@formatjs/intl-numberformat/locale-data/pt-PT.js'),
import('@formatjs/intl-displaynames/locale-data/pt-PT.js'),
])
return dateLocale
}
case AppLanguage.ro: {
i18n.loadAndActivate({locale, messages: messagesRo})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ro'),
import('@formatjs/intl-pluralrules/locale-data/ro'),
import('@formatjs/intl-numberformat/locale-data/ro'),
import('@formatjs/intl-displaynames/locale-data/ro'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ro').then(m => m.ro),
import('@formatjs/intl-pluralrules/locale-data/ro.js'),
import('@formatjs/intl-numberformat/locale-data/ro.js'),
import('@formatjs/intl-displaynames/locale-data/ro.js'),
])
return dateLocale
}
case AppLanguage.ru: {
i18n.loadAndActivate({locale, messages: messagesRu})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ru'),
import('@formatjs/intl-pluralrules/locale-data/ru'),
import('@formatjs/intl-numberformat/locale-data/ru'),
import('@formatjs/intl-displaynames/locale-data/ru'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ru').then(m => m.ru),
import('@formatjs/intl-pluralrules/locale-data/ru.js'),
import('@formatjs/intl-numberformat/locale-data/ru.js'),
import('@formatjs/intl-displaynames/locale-data/ru.js'),
])
return dateLocale
}
case AppLanguage.sv: {
i18n.loadAndActivate({locale, messages: messagesSv})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/sv'),
import('@formatjs/intl-pluralrules/locale-data/sv'),
import('@formatjs/intl-numberformat/locale-data/sv'),
import('@formatjs/intl-displaynames/locale-data/sv'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/sv').then(m => m.sv),
import('@formatjs/intl-pluralrules/locale-data/sv.js'),
import('@formatjs/intl-numberformat/locale-data/sv.js'),
import('@formatjs/intl-displaynames/locale-data/sv.js'),
])
return dateLocale
}
case AppLanguage.th: {
i18n.loadAndActivate({locale, messages: messagesTh})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/th'),
import('@formatjs/intl-pluralrules/locale-data/th'),
import('@formatjs/intl-numberformat/locale-data/th'),
import('@formatjs/intl-displaynames/locale-data/th'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/th').then(m => m.th),
import('@formatjs/intl-pluralrules/locale-data/th.js'),
import('@formatjs/intl-numberformat/locale-data/th.js'),
import('@formatjs/intl-displaynames/locale-data/th.js'),
])
return dateLocale
}
case AppLanguage.tr: {
i18n.loadAndActivate({locale, messages: messagesTr})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/tr'),
import('@formatjs/intl-pluralrules/locale-data/tr'),
import('@formatjs/intl-numberformat/locale-data/tr'),
import('@formatjs/intl-displaynames/locale-data/tr'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/tr').then(m => m.tr),
import('@formatjs/intl-pluralrules/locale-data/tr.js'),
import('@formatjs/intl-numberformat/locale-data/tr.js'),
import('@formatjs/intl-displaynames/locale-data/tr.js'),
])
return dateLocale
}
case AppLanguage.uk: {
i18n.loadAndActivate({locale, messages: messagesUk})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/uk'),
import('@formatjs/intl-pluralrules/locale-data/uk'),
import('@formatjs/intl-numberformat/locale-data/uk'),
import('@formatjs/intl-displaynames/locale-data/uk'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/uk').then(m => m.uk),
import('@formatjs/intl-pluralrules/locale-data/uk.js'),
import('@formatjs/intl-numberformat/locale-data/uk.js'),
import('@formatjs/intl-displaynames/locale-data/uk.js'),
])
return dateLocale
}
case AppLanguage.vi: {
i18n.loadAndActivate({locale, messages: messagesVi})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/vi'),
import('@formatjs/intl-pluralrules/locale-data/vi'),
import('@formatjs/intl-numberformat/locale-data/vi'),
import('@formatjs/intl-displaynames/locale-data/vi'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/vi').then(m => m.vi),
import('@formatjs/intl-pluralrules/locale-data/vi.js'),
import('@formatjs/intl-numberformat/locale-data/vi.js'),
import('@formatjs/intl-displaynames/locale-data/vi.js'),
])
return dateLocale
}
case AppLanguage.zh_CN: {
i18n.loadAndActivate({locale, messages: messagesZh_CN})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-CN'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/zh-CN').then(m => m.zhCN),
import('@formatjs/intl-pluralrules/locale-data/zh.js'),
import('@formatjs/intl-numberformat/locale-data/zh.js'),
import('@formatjs/intl-displaynames/locale-data/zh.js'),
])
return dateLocale
}
case AppLanguage.zh_HK: {
i18n.loadAndActivate({locale, messages: messagesZh_HK})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-HK'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/zh-HK').then(m => m.zhHK),
import('@formatjs/intl-pluralrules/locale-data/zh.js'),
import('@formatjs/intl-numberformat/locale-data/yue-Hant.js'),
import('@formatjs/intl-displaynames/locale-data/yue-Hant.js'),
])
return dateLocale
}
case AppLanguage.zh_TW: {
i18n.loadAndActivate({locale, messages: messagesZh_TW})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-TW'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/zh-TW').then(m => m.zhTW),
import('@formatjs/intl-pluralrules/locale-data/zh.js'),
import('@formatjs/intl-numberformat/locale-data/zh-Hant.js'),
import('@formatjs/intl-displaynames/locale-data/zh-Hant.js'),
])
return dateLocale
}
@@ -472,9 +471,11 @@ export function useLocaleLanguage() {
const [dateLocale, setDateLocale] = useState(defaultLocale)
useEffect(() => {
dynamicActivate(sanitizeAppLanguageSetting(appLanguage)).then(locale => {
setDateLocale(locale ?? defaultLocale)
})
void dynamicActivate(sanitizeAppLanguageSetting(appLanguage)).then(
locale => {
setDateLocale(locale ?? defaultLocale)
},
)
}, [appLanguage])
return dateLocale
+121 -120
View File
@@ -1,6 +1,7 @@
import {useEffect, useState} from 'react'
import {i18n} from '@lingui/core'
import defaultLocale from 'date-fns/locale/en-US'
import {i18n, type Messages} from '@lingui/core'
import {type Locale} from 'date-fns/locale'
import {enUS as defaultLocale} from 'date-fns/locale/en-US'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -10,288 +11,288 @@ import {useLanguagePrefs} from '#/state/preferences'
* We do a dynamic import of just the catalog that we need
*/
export async function dynamicActivate(locale: AppLanguage) {
let mod: any
let messages: Messages
let dateLocale: Locale = defaultLocale
switch (locale) {
case AppLanguage.an: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/an/messages`),
import('date-fns/locale/es'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/an/messages`).then(m => m.messages),
import('date-fns/locale/es').then(m => m.es),
])
break
}
case AppLanguage.ast: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ast/messages`),
import('date-fns/locale/es'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ast/messages`).then(m => m.messages),
import('date-fns/locale/es').then(m => m.es),
])
break
}
case AppLanguage.ca: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ca/messages`),
import('date-fns/locale/ca'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ca/messages`).then(m => m.messages),
import('date-fns/locale/ca').then(m => m.ca),
])
break
}
case AppLanguage.cy: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/cy/messages`),
import('date-fns/locale/cy'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/cy/messages`).then(m => m.messages),
import('date-fns/locale/cy').then(m => m.cy),
])
break
}
case AppLanguage.da: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/da/messages`),
import('date-fns/locale/da'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/da/messages`).then(m => m.messages),
import('date-fns/locale/da').then(m => m.da),
])
break
}
case AppLanguage.de: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/de/messages`),
import('date-fns/locale/de'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/de/messages`).then(m => m.messages),
import('date-fns/locale/de').then(m => m.de),
])
break
}
case AppLanguage.el: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/el/messages`),
import('date-fns/locale/el'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/el/messages`).then(m => m.messages),
import('date-fns/locale/el').then(m => m.el),
])
break
}
case AppLanguage.en_GB: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/en-GB/messages`),
import('date-fns/locale/en-GB'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/en-GB/messages`).then(m => m.messages),
import('date-fns/locale/en-GB').then(m => m.enGB),
])
break
}
case AppLanguage.eo: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/eo/messages`),
import('date-fns/locale/eo'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/eo/messages`).then(m => m.messages),
import('date-fns/locale/eo').then(m => m.eo),
])
break
}
case AppLanguage.es: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/es/messages`),
import('date-fns/locale/es'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/es/messages`).then(m => m.messages),
import('date-fns/locale/es').then(m => m.es),
])
break
}
case AppLanguage.eu: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/eu/messages`),
import('date-fns/locale/eu'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/eu/messages`).then(m => m.messages),
import('date-fns/locale/eu').then(m => m.eu),
])
break
}
case AppLanguage.fi: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fi/messages`),
import('date-fns/locale/fi'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/fi/messages`).then(m => m.messages),
import('date-fns/locale/fi').then(m => m.fi),
])
break
}
case AppLanguage.fr: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fr/messages`),
import('date-fns/locale/fr'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/fr/messages`).then(m => m.messages),
import('date-fns/locale/fr').then(m => m.fr),
])
break
}
case AppLanguage.fy: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fy/messages`),
import('date-fns/locale/fy'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/fy/messages`).then(m => m.messages),
import('date-fns/locale/fy').then(m => m.fy),
])
break
}
case AppLanguage.ga: {
mod = await import(`./locales/ga/messages`)
messages = await import(`./locales/ga/messages`).then(m => m.messages)
break
}
case AppLanguage.gd: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/gd/messages`),
import('date-fns/locale/gd'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/gd/messages`).then(m => m.messages),
import('date-fns/locale/gd').then(m => m.gd),
])
break
}
case AppLanguage.gl: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/gl/messages`),
import('date-fns/locale/gl'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/gl/messages`).then(m => m.messages),
import('date-fns/locale/gl').then(m => m.gl),
])
break
}
case AppLanguage.hi: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/hi/messages`),
import('date-fns/locale/hi'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/hi/messages`).then(m => m.messages),
import('date-fns/locale/hi').then(m => m.hi),
])
break
}
case AppLanguage.hu: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/hu/messages`),
import('date-fns/locale/hu'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/hu/messages`).then(m => m.messages),
import('date-fns/locale/hu').then(m => m.hu),
])
break
}
case AppLanguage.ia: {
mod = await import(`./locales/ia/messages`)
messages = await import(`./locales/ia/messages`).then(m => m.messages)
break
}
case AppLanguage.id: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/id/messages`),
import('date-fns/locale/id'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/id/messages`).then(m => m.messages),
import('date-fns/locale/id').then(m => m.id),
])
break
}
case AppLanguage.it: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/it/messages`),
import('date-fns/locale/it'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/it/messages`).then(m => m.messages),
import('date-fns/locale/it').then(m => m.it),
])
break
}
case AppLanguage.ja: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ja/messages`),
import('date-fns/locale/ja'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ja/messages`).then(m => m.messages),
import('date-fns/locale/ja').then(m => m.ja),
])
break
}
case AppLanguage.km: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/km/messages`),
import('date-fns/locale/km'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/km/messages`).then(m => m.messages),
import('date-fns/locale/km').then(m => m.km),
])
break
}
case AppLanguage.ko: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ko/messages`),
import('date-fns/locale/ko'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ko/messages`).then(m => m.messages),
import('date-fns/locale/ko').then(m => m.ko),
])
break
}
case AppLanguage.ne: {
mod = await import(`./locales/ne/messages`)
messages = await import(`./locales/ne/messages`).then(m => m.messages)
break
}
case AppLanguage.nl: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/nl/messages`),
import('date-fns/locale/nl'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/nl/messages`).then(m => m.messages),
import('date-fns/locale/nl').then(m => m.nl),
])
break
}
case AppLanguage.pl: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pl/messages`),
import('date-fns/locale/pl'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/pl/messages`).then(m => m.messages),
import('date-fns/locale/pl').then(m => m.pl),
])
break
}
case AppLanguage.pt_BR: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pt-BR/messages`),
import('date-fns/locale/pt-BR'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/pt-BR/messages`).then(m => m.messages),
import('date-fns/locale/pt-BR').then(m => m.ptBR),
])
break
}
case AppLanguage.pt_PT: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pt-PT/messages`),
import('date-fns/locale/pt'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/pt-PT/messages`).then(m => m.messages),
import('date-fns/locale/pt').then(m => m.pt),
])
break
}
case AppLanguage.ro: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ro/messages`),
import('date-fns/locale/ro'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ro/messages`).then(m => m.messages),
import('date-fns/locale/ro').then(m => m.ro),
])
break
}
case AppLanguage.ru: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ru/messages`),
import('date-fns/locale/ru'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ru/messages`).then(m => m.messages),
import('date-fns/locale/ru').then(m => m.ru),
])
break
}
case AppLanguage.sv: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/sv/messages`),
import('date-fns/locale/sv'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/sv/messages`).then(m => m.messages),
import('date-fns/locale/sv').then(m => m.sv),
])
break
}
case AppLanguage.th: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/th/messages`),
import('date-fns/locale/th'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/th/messages`).then(m => m.messages),
import('date-fns/locale/th').then(m => m.th),
])
break
}
case AppLanguage.tr: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/tr/messages`),
import('date-fns/locale/tr'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/tr/messages`).then(m => m.messages),
import('date-fns/locale/tr').then(m => m.tr),
])
break
}
case AppLanguage.uk: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/uk/messages`),
import('date-fns/locale/uk'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/uk/messages`).then(m => m.messages),
import('date-fns/locale/uk').then(m => m.uk),
])
break
}
case AppLanguage.vi: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/vi/messages`),
import('date-fns/locale/vi'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/vi/messages`).then(m => m.messages),
import('date-fns/locale/vi').then(m => m.vi),
])
break
}
case AppLanguage.zh_CN: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-CN/messages`),
import('date-fns/locale/zh-CN'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/zh-CN/messages`).then(m => m.messages),
import('date-fns/locale/zh-CN').then(m => m.zhCN),
])
break
}
case AppLanguage.zh_HK: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-HK/messages`),
import('date-fns/locale/zh-HK'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/zh-HK/messages`).then(m => m.messages),
import('date-fns/locale/zh-HK').then(m => m.zhHK),
])
break
}
case AppLanguage.zh_TW: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-TW/messages`),
import('date-fns/locale/zh-TW'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/zh-TW/messages`).then(m => m.messages),
import('date-fns/locale/zh-TW').then(m => m.zhTW),
])
break
}
default: {
mod = await import(`./locales/en/messages`)
messages = await import(`./locales/en/messages`).then(m => m.messages)
break
}
}
i18n.load(locale, mod.messages)
i18n.load(locale, messages)
i18n.activate(locale)
return dateLocale
@@ -305,7 +306,7 @@ export function useLocaleLanguage() {
const sanitizedLanguage = sanitizeAppLanguageSetting(appLanguage)
document.documentElement.lang = sanitizedLanguage
dynamicActivate(sanitizedLanguage).then(locale => {
void dynamicActivate(sanitizedLanguage).then(locale => {
setDateLocale(locale)
})
}, [appLanguage])
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,4 +1,4 @@
import format from 'date-fns/format'
import {format} from 'date-fns/format'
import {LogLevel, type Transport} from '#/logger/types'
import {prepareMetadata} from '#/logger/util'
+2 -1
View File
@@ -25,7 +25,8 @@ if (process.env.NODE_ENV !== 'production') {
thrownErrors.add(err)
throw err
} else if (!thrownErrors.has(msgOrError)) {
return realConsoleError.apply(this, arguments as any)
// @ts-expect-error
return realConsoleError.apply(this, arguments)
}
}
}
+1
View File
@@ -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,13 +47,16 @@ 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`,
comment:
'Displayed when there are more than 50 requests to join a group chat',
'Displayed when there are more than 20 requests to join a group chat',
})
: l({
message: plural(requestCount, {
@@ -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}
+305
View File
@@ -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>
)
}
+491
View File
@@ -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 couldnt load this conversations 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>
)
}
@@ -6,6 +6,7 @@ import {
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
@@ -213,6 +214,20 @@ function GroupChatItem({
primaryProfileModeration={moderation}
isBlockedAccount={false}
isDeletedAccount={false}
subtitle={
convo.details.joinRequestCount
? convo.details.joinRequestCount > 20
? l({
message: '20+ new join requests',
context:
'Displayed when there are more than 20 requests to join a group chat',
})
: plural(convo.details.joinRequestCount, {
one: '# new join request',
other: '# new join requests',
})
: undefined
}
showProfileBadges={false}
selected={selected}
showMenu={showMenu}>
@@ -35,13 +35,14 @@ export function EditTextButton({
a.flex_row,
a.align_center,
a.justify_between,
a.px_md,
a.pl_lg,
a.pr_sm,
a.py_sm,
a.gap_sm,
]}>
{typeof children === 'function' ? children(context) : children}
<View
style={[
a.ml_sm,
a.rounded_full,
t.atoms.bg_contrast_50,
{paddingHorizontal: 10, paddingVertical: 8},
@@ -1,6 +1,11 @@
import {useEffect, useState} from 'react'
import {useState} from 'react'
import {View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {
type ChatBskyGroupDefs,
moderateProfile,
type ModerationOpts,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
@@ -64,14 +69,9 @@ export function InviteLinkDialog({
)
const {joinLink} = convo.details
const enabledStatus = joinLink?.enabledStatus
const defaultStep = joinLink ? Step.MANAGE : Step.INFO
const defaultWhoCanJoin = joinLink
? [
`${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`,
]
: ['anyone']
const defaultWhoCanJoin = joinLink ? joinLinkToKey(joinLink) : 'anyone'
const [step, setStep] = useState(defaultStep)
const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin)
@@ -79,13 +79,13 @@ export function InviteLinkDialog({
// Resync local state when the server-side join link rules change (mutation
// success, refetch, or change from another client). Keyed on the rule string
// so identity-only refetches don't bump a user mid-edit.
const joinLinkRuleKey = joinLink
? `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`
: null
useEffect(() => {
const joinLinkRuleKey = joinLink ? joinLinkToKey(joinLink) : null
const [prevKey, setPrevKey] = useState(joinLinkRuleKey)
if (joinLinkRuleKey !== prevKey) {
setStep(joinLinkRuleKey ? Step.MANAGE : Step.INFO)
setWhoCanJoin([joinLinkRuleKey ?? 'anyone'])
}, [joinLinkRuleKey])
setWhoCanJoin(joinLinkRuleKey ?? 'anyone')
setPrevKey(joinLinkRuleKey)
}
const {openComposer} = useOpenComposer()
@@ -163,22 +163,32 @@ export function InviteLinkDialog({
let content: React.ReactNode = null
let header: string | null = null
switch (step) {
case Step.INFO:
case Step.INFO: {
header = l`Invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<View style={[a.gap_lg]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
An invite link lets people join this group chat without being
added directly. You control who can use the link and whether
they need your approval. You can disable the link at any time.
added directly. You control who can join the chat. You can
disable the link at any time.
</Trans>
</Text>
<Text style={[a.mt_lg, a.text_md, t.atoms.text]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Your name, avatar, and the name of the group chat will be
visible to everyone.
Group chats can only have a maximum of{' '}
{plural(convo.details.memberLimit, {
one: '# person',
other: '# people',
})}
.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Your name, avatar, the name of the group chat, and the number of
members will be visible to everyone.
</Trans>
</Text>
</View>
@@ -199,15 +209,16 @@ export function InviteLinkDialog({
</>
)
break
case Step.GENERATE:
header =
joinLink && enabledStatus === 'enabled'
? l`Update invite link`
: l`Generate invite link`
}
case Step.GENERATE: {
const linkEnabled = joinLink?.enabledStatus === 'enabled'
const linkHasChanged = linkEnabled && joinLinkRuleKey !== whoCanJoin
header = linkEnabled ? l`Update invite link` : l`Generate invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<Text style={[a.text_md]}>
<Trans>Choose who can join this group chat and how.</Trans>
</Text>
</View>
@@ -215,9 +226,9 @@ export function InviteLinkDialog({
<Toggle.Group
label={l`Who can join this group chat and how`}
type="radio"
values={whoCanJoin}
onChange={setWhoCanJoin}>
<View style={[a.gap_sm]}>
values={[whoCanJoin]}
onChange={([value]) => setWhoCanJoin(value)}>
<View style={[a.gap_xs]}>
{whoCanJoinOptions.map(option => (
<Toggle.Item
key={option.name}
@@ -239,18 +250,22 @@ export function InviteLinkDialog({
<View style={[a.mt_4xl]}>
<Button
label={
joinLink && enabledStatus === 'enabled'
? l`Update invite link`
linkEnabled
? linkHasChanged
? l`Update invite link`
: l`Back`
: l`Generate invite link`
}
color="primary"
color={linkEnabled && !linkHasChanged ? 'secondary' : 'primary'}
size="large"
disabled={isSaving}
onPress={() => {
const parts = whoCanJoin[0].split(':')
const joinRule = parts[0]
const requireApproval = parts[1] === 'requireApproval'
if (joinLink && enabledStatus === 'enabled') {
const {joinRule, requireApproval} = keyToJoinLink(whoCanJoin)
if (linkEnabled) {
if (!linkHasChanged) {
setStep(Step.MANAGE)
return
}
editJoinLink({
joinRule,
requireApproval,
@@ -263,36 +278,40 @@ export function InviteLinkDialog({
}
}}>
<ButtonText>
{joinLink && enabledStatus === 'enabled'
? l`Update invite link`
{linkEnabled
? linkHasChanged
? l`Update invite link`
: l`Back`
: l`Generate invite link`}
</ButtonText>
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
{linkHasChanged && (
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
)}
</Button>
</View>
</>
)
break
}
case Step.MANAGE: {
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 currentOptionName = joinLink
? `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`
: whoCanJoinOptions[0].name
const currentOption = whoCanJoinOptions.find(
o => o.name === currentOptionName,
)
const currentOption =
whoCanJoinOptions.find(
o => o.name === (joinLink ? joinLinkToKey(joinLink) : null),
) ?? whoCanJoinOptions[0]
const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner
const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member
header =
enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled`
header = linkEnabled ? l`Invite link` : l`Invite link disabled`
content = (
<>
<View style={[a.mt_lg]}>
<CopyTextButton
disabled={enabledStatus === 'disabled' || !joinLink?.code}
disabled={linkDisabled || !joinLink?.code}
label={l`Invite link`}
value={joinLinkURI}>
<Text
@@ -300,9 +319,7 @@ export function InviteLinkDialog({
style={[
a.mr_xs,
a.text_md,
enabledStatus === 'disabled'
? t.atoms.text_contrast_low
: t.atoms.text,
linkDisabled ? t.atoms.text_contrast_low : t.atoms.text,
]}>
{joinLinkURI}
</Text>
@@ -319,25 +336,25 @@ export function InviteLinkDialog({
</Text>
) : null}
</View>
{enabledStatus === 'enabled' ? (
{linkEnabled ? (
<View style={[a.mt_lg]}>
{isOwner ? (
<EditTextButton
label={l`Edit link settings`}
value={ownerValue}
onPress={() => setStep(Step.GENERATE)}>
<View style={[a.flex_1, a.mr_xs]}>
<Text numberOfLines={1} style={[a.text_md, t.atoms.text]}>
<View style={[a.flex_1]}>
<Text numberOfLines={1} style={[a.text_sm]}>
{ownerValue}
</Text>
</View>
</EditTextButton>
) : (
<Text style={[a.text_sm, t.atoms.text]}>{memberValue}</Text>
<Text style={[a.text_sm]}>{memberValue}</Text>
)}
</View>
) : null}
{enabledStatus === 'enabled' ? (
{linkEnabled ? (
<View style={[a.flex_row, a.justify_between, a.gap_sm, a.mt_lg]}>
{isOwner ? (
<StackedButton
@@ -350,7 +367,7 @@ export function InviteLinkDialog({
</StackedButton>
) : null}
<StackedButton
disabled={enabledStatus === 'disabled'}
disabled={linkDisabled}
label={l`Post link`}
icon={EditIcon}
color="primary_subtle"
@@ -366,7 +383,7 @@ export function InviteLinkDialog({
<Trans>Post link</Trans>
</StackedButton>
<StackedButton
disabled={enabledStatus === 'disabled'}
disabled={linkDisabled}
label={l`Share`}
icon={ArrowShareRightIcon}
color="primary_subtle"
@@ -408,7 +425,7 @@ export function InviteLinkDialog({
)
break
}
case Step.CONFIRM_DISABLE:
case Step.CONFIRM_DISABLE: {
content = (
<>
<View style={[a.align_center, a.justify_center, a.mb_lg]}>
@@ -425,14 +442,7 @@ export function InviteLinkDialog({
]}>
<Trans>Disable this invite link?</Trans>
</Text>
<Text
style={[
a.pb_2xl,
a.text_center,
a.text_sm,
a.leading_snug,
t.atoms.text,
]}>
<Text style={[a.pb_2xl, a.text_center, a.text_sm, a.leading_snug]}>
<Trans>
Anyone who has it will no longer be able to join or request to
join. You can always create a new one.
@@ -465,6 +475,7 @@ export function InviteLinkDialog({
</>
)
break
}
}
if (!isOwner && (!joinLink || joinLink.enabledStatus === 'disabled')) {
@@ -472,7 +483,7 @@ export function InviteLinkDialog({
content = (
<>
<View style={[a.mt_lg]}>
<Text style={[a.text_sm, t.atoms.text]}>
<Text style={[a.text_sm]}>
<Trans>There is no invite link for this group chat.</Trans>
</Text>
</View>
@@ -503,9 +514,7 @@ export function InviteLinkDialog({
header={
<View>
<View style={[IS_WEB ? [a.px_2xl, a.pt_xl] : {paddingTop: 10}]}>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm, t.atoms.text]}>
{header}
</Text>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>{header}</Text>
</View>
<Dialog.Close />
</View>
@@ -517,3 +526,17 @@ export function InviteLinkDialog({
</Dialog.Outer>
)
}
function joinLinkToKey(joinLink: ChatBskyGroupDefs.JoinLinkView): string {
return `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`
}
function keyToJoinLink(
key: string,
): Pick<ChatBskyGroupDefs.JoinLinkView, 'joinRule' | 'requireApproval'> {
const [joinRule, requireApproval] = key.split(':')
return {
joinRule,
requireApproval: requireApproval === 'requireApproval',
}
}
+125 -118
View File
@@ -56,6 +56,7 @@ import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {DateDivider} from '#/components/dms/DateDivider'
import {MessageItem} from '#/components/dms/MessageItem'
import {MessageOverlays} from '#/components/dms/MessageOverlays'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
@@ -498,127 +499,133 @@ export function MessagesList({
return (
<InviteLinkDialogProvider convo={convoState.convo}>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
// slightly too buggy unfortunately, enable when possible
// textInputNativeID={textInputId}
style={[a.flex_1]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<Animated.View style={[a.flex_1, animatedListStyle]}>
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={renderItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
disableVirtualization={true}
// The extra two items account for the header and the footer components
initialNumToRender={IS_NATIVE ? 32 : 62}
maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{minIndexForVisible: 0}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
showsVerticalScrollIndicator={!IS_ANDROID}
scrollEventThrottle={100}
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.hasAllHistory ? (
convoState.convo?.kind === 'group' ? (
<MessagesListGroupInfoPanel convo={convoState.convo} />
) : (
<MessagesListInfoPanel convo={convoState.convo} />
)
) : null}
</>
}
// native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent}
contentContainerStyle={{
paddingBottom: platform({
// ios is slightly larger as the input has no top padding
ios: tokens.space.lg,
android: tokens.space.md,
web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
}}
ListFooterComponent={
<View
style={web({height: tokens.space.md + inputHeightJS})}
onLayout={onFooterLayout}
/>
}
style={[
web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable',
}),
]}
pointerEvents={!hasScrolled ? 'none' : 'auto'}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
</Animated.View>
<KeyboardStickyView
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
onLayout={onInputLayout}
minimumOffset={bottomInset}
offset={{
closed: platform({
ios: tokens.space.lg, // hide bottom padding when closed
default: 0,
}),
opened: 0,
}}>
{footer ?? (
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
{({loading}) =>
ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
<MessageOverlays>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
// slightly too buggy unfortunately, enable when possible
// textInputNativeID={textInputId}
style={[a.flex_1]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<Animated.View style={[a.flex_1, animatedListStyle]}>
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={renderItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
disableVirtualization={true}
// The extra two items account for the header and the footer components
initialNumToRender={IS_NATIVE ? 32 : 62}
maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{minIndexForVisible: 0}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
showsVerticalScrollIndicator={!IS_ANDROID}
scrollEventThrottle={100}
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.hasAllHistory ? (
convoState.convo?.kind === 'group' ? (
<MessagesListGroupInfoPanel convo={convoState.convo} />
) : (
<MessagesListInfoPanel convo={convoState.convo} />
)
) : null}
</>
}
// native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent}
contentContainerStyle={{
paddingBottom: platform({
// ios is slightly larger as the input has no top padding
ios: tokens.space.lg,
android: tokens.space.md,
web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
}}
ListFooterComponent={
<View
style={web({height: tokens.space.md + inputHeightJS})}
onLayout={onFooterLayout}
/>
}
style={[
web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable',
}),
]}
pointerEvents={!hasScrolled ? 'none' : 'auto'}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
</Animated.View>
<KeyboardStickyView
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
onLayout={onInputLayout}
minimumOffset={bottomInset}
offset={{
closed: platform({
ios: tokens.space.lg, // hide bottom padding when closed
default: 0,
}),
opened: 0,
}}>
{footer ?? (
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
{({loading}) =>
ax.features.enabled(
ax.features.DmsNewMessageComposerEnable,
) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
/>
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
setEmbed={setEmbed}
/>
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
/>
</MessageInput>
)
}
</ConversationFooter>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
setEmbed={setEmbed}
/>
</MessageInput>
)
}
</ConversationFooter>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
{newMessagesPill.show && (
<NewMessagesPill onPress={scrollToEndOnPress} />
)}
</MessageOverlays>
</InviteLinkDialogProvider>
)
}
@@ -43,14 +43,11 @@ export function MessagesListGroupInfoPanel({
},
})
// TODO Enable this once the feature is working end-to-end. -dsb
// const joinLink = groupConvo?.details.joinLink
const isJoinLinkEnabled = false
// (isOwner && groupConvo) ||
// (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
const isOwner = convo.primaryMember?.did === currentAccount?.did
const isJoinLinkEnabled =
isOwner || convo.details.joinLink?.enabledStatus === 'enabled'
const members = (convo.members ?? []).filter(
profile => profile.did !== currentAccount?.did,
)
@@ -28,6 +28,9 @@ export function RequestListItem({
const isDeletedAccount =
!convo.primaryMember || convo.primaryMember.handle === 'missing.invalid'
const canAcceptRequest =
convo.kind === 'direct' || convo.details.lockStatus === 'unlocked'
return (
<View style={[a.relative, a.flex_1]}>
<ChatListItem convo={convo.view} showMenu={false}>
@@ -65,7 +68,9 @@ export function RequestListItem({
]}>
{convo.primaryMember && !isDeletedAccount ? (
<>
<AcceptChatButton convo={convo.view} currentScreen="list" />
{canAcceptRequest ? (
<AcceptChatButton convo={convo.view} currentScreen="list" />
) : null}
<RejectMenu
convo={convo.view}
profile={convo.primaryMember}
@@ -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,
@@ -129,7 +129,7 @@ function getAppIconName(icon: string | false): DynamicAppIcon.IconName {
if (!icon || icon === 'DEFAULT') {
return 'default_light'
} else {
return icon as DynamicAppIcon.IconName
return icon
}
}
@@ -151,7 +151,7 @@ function Group({
values={[value]}
maxSelections={1}
onChange={vals => {
if (vals[0]) onChange(vals[0] as DynamicAppIcon.IconName)
if (vals[0]) onChange(vals[0])
}}>
<View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
{children}
@@ -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]}>
+1 -1
View File
@@ -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'
@@ -23,8 +23,7 @@ import {
import {useEventListener} from 'expo'
import {type VideoPlayer} from 'expo-video'
import {tokens} from '#/alf'
import {atoms as a} from '#/alf'
import {atoms as a, tokens} from '#/alf'
import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils'
import {Text} from '#/components/Typography'
+3 -11
View File
@@ -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
+2 -2
View File
@@ -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,
})),
+72
View File
@@ -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,
})
}
}
+129
View File
@@ -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)
},
})
}
@@ -489,12 +489,22 @@ export function ListConvosProviderInner({
// Join link data not included in the log event, trigger refetch to get it
debouncedRefetch()
} else if (
ChatBskyConvoDefs.isLogIncomingJoinRequest(log) ||
ChatBskyConvoDefs.isLogApproveJoinRequest(log) ||
ChatBskyConvoDefs.isLogRejectJoinRequest(log) ||
ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)
ChatBskyConvoDefs.isLogRejectJoinRequest(log)
) {
// TODO Update join request count here when available. -dsb
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
updateGroupConvoJoinRequestCount(log, old, -1),
)
} else if (ChatBskyConvoDefs.isLogIncomingJoinRequest(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
updateGroupConvoJoinRequestCount(log, old, 1),
)
} else if (ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)) {
// Viewer isn't in the chat yet, no need to do anything
} else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
@@ -762,6 +772,29 @@ function optimisticUpdate(
}
}
function updateGroupConvoJoinRequestCount(
log: {convoId: string; rev: string},
old: ConvoListQueryData | undefined,
delta: 1 | -1,
) {
return optimisticUpdate(log.convoId, old, convo => {
// Join requests are only meaningful for group convos.
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {...convo, rev: log.rev}
}
const current = convo.kind.joinRequestCount ?? 0
const next = Math.max(0, current + delta)
return {
...convo,
kind: {
...convo.kind,
joinRequestCount: next === 0 ? undefined : next,
},
rev: log.rev,
}
})
}
function removeMemberFromConvoView(
convo: ChatBskyConvoDefs.ConvoView,
did: string,
@@ -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)
},
})
}
+1 -1
View File
@@ -298,7 +298,7 @@ export function* findAllPostsInQueryData(
if (AppBskyFeedDefs.isPostView(item.subject)) {
const quotedPost = getEmbeddedPost(item.subject?.embed)
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
yield embedViewRecordToPostView(quotedPost!)
yield embedViewRecordToPostView(quotedPost)
}
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ export function parseAppNux(nux: AppBskyActorDefs.Nux): AppNux | undefined {
export function serializeAppNux(nux: AppNux): AppBskyActorDefs.Nux {
const {data, ...rest} = nux
const schema = NuxSchemas[nux.id as Nux]
const schema = NuxSchemas[nux.id]
const result: AppBskyActorDefs.Nux = {
...rest,
+1 -1
View File
@@ -85,7 +85,7 @@ export function useSuggestedFollowsByActorWithDismiss({
const profiles = useMemo(() => {
return (data?.suggestions ?? []).map(profile => ({
actor: profile as bsky.profile.AnyProfileView,
actor: profile,
recId: data?.recId,
}))
}, [data?.suggestions, data?.recId])

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